contractgate 0.1.0__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,107 @@
1
+ """ContractGate Python SDK.
2
+
3
+ First-party client and pure-Python validator for the ContractGate
4
+ semantic contract enforcement gateway.
5
+
6
+ Public surface:
7
+ Client, AsyncClient -- HTTP clients (sync, async)
8
+ Contract, CompiledContract -- local contract parse + compile
9
+ FieldDefinition, FieldType -- ontology types
10
+ MetricDefinition, MetricType -- metric types
11
+ Transform, TransformKind, -- RFC-004 declarations (declared, not run)
12
+ MaskStyle
13
+ ValidationResult, Violation, -- validator outputs
14
+ ViolationKind
15
+ BatchIngestResponse, -- HTTP response shapes
16
+ IngestEventResult,
17
+ AuditEntry, ContractResponse,
18
+ VersionResponse, VersionSummary,
19
+ IngestionStats
20
+ ContractGateError, HTTPError, -- error hierarchy
21
+ BadRequestError, AuthError,
22
+ NotFoundError, ConflictError,
23
+ ValidationFailedError,
24
+ ServerError, ConnectionError,
25
+ ContractCompileError
26
+
27
+ See README.md for usage. See ../docs/rfcs/005-python-sdk.md for the
28
+ design rationale.
29
+ """
30
+
31
+ from contractgate._version import __version__
32
+ from contractgate.async_client import AsyncClient
33
+ from contractgate.client import Client
34
+ from contractgate.contract import (
35
+ CompiledContract,
36
+ Contract,
37
+ FieldDefinition,
38
+ FieldType,
39
+ MaskStyle,
40
+ MetricDefinition,
41
+ MetricType,
42
+ Transform,
43
+ TransformKind,
44
+ )
45
+ from contractgate.exceptions import (
46
+ AuthError,
47
+ BadRequestError,
48
+ ConflictError,
49
+ ConnectionError,
50
+ ContractCompileError,
51
+ ContractGateError,
52
+ HTTPError,
53
+ NotFoundError,
54
+ ServerError,
55
+ ValidationFailedError,
56
+ )
57
+ from contractgate.models import (
58
+ AuditEntry,
59
+ BatchIngestResponse,
60
+ ContractResponse,
61
+ IngestEventResult,
62
+ IngestionStats,
63
+ ValidationResult,
64
+ VersionResponse,
65
+ VersionSummary,
66
+ Violation,
67
+ ViolationKind,
68
+ )
69
+
70
+ __all__ = [
71
+ "__version__",
72
+ # Clients
73
+ "Client",
74
+ "AsyncClient",
75
+ # Contract / validator
76
+ "Contract",
77
+ "CompiledContract",
78
+ "FieldDefinition",
79
+ "FieldType",
80
+ "MetricDefinition",
81
+ "MetricType",
82
+ "Transform",
83
+ "TransformKind",
84
+ "MaskStyle",
85
+ # Models
86
+ "ValidationResult",
87
+ "Violation",
88
+ "ViolationKind",
89
+ "BatchIngestResponse",
90
+ "IngestEventResult",
91
+ "AuditEntry",
92
+ "ContractResponse",
93
+ "VersionResponse",
94
+ "VersionSummary",
95
+ "IngestionStats",
96
+ # Errors
97
+ "ContractGateError",
98
+ "HTTPError",
99
+ "BadRequestError",
100
+ "AuthError",
101
+ "NotFoundError",
102
+ "ConflictError",
103
+ "ValidationFailedError",
104
+ "ServerError",
105
+ "ConnectionError",
106
+ "ContractCompileError",
107
+ ]
@@ -0,0 +1,280 @@
1
+ """Shared transport wiring for sync + async HTTP clients.
2
+
3
+ Centralizes:
4
+ - URL building (``base_url`` + path joining),
5
+ - default headers (``x-api-key``, optional ``x-org-id``,
6
+ ``User-Agent``),
7
+ - request shaping for ingest, audit, contract reads,
8
+ - response decode + error mapping (``status_to_exception``).
9
+
10
+ Both ``Client`` (httpx.Client) and ``AsyncClient`` (httpx.AsyncClient)
11
+ delegate request building here so they cannot drift on auth or path
12
+ shape. They differ only in *how* the request is dispatched (sync vs
13
+ ``await``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
21
+
22
+ from contractgate._version import __version__
23
+ from contractgate.exceptions import raise_for_status
24
+
25
+ # Default timeout matches the gateway's own 30s upper bound (see
26
+ # ``TimeoutLayer`` in ``src/main.rs``). Callers can override per-call.
27
+ DEFAULT_TIMEOUT_S = 30.0
28
+
29
+ USER_AGENT = f"contractgate-python/{__version__}"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TransportConfig:
34
+ base_url: str
35
+ api_key: Optional[str]
36
+ org_id: Optional[str]
37
+ timeout: float
38
+
39
+ def headers(self, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
40
+ h: Dict[str, str] = {
41
+ "User-Agent": USER_AGENT,
42
+ "Accept": "application/json",
43
+ }
44
+ if self.api_key:
45
+ h["x-api-key"] = self.api_key
46
+ if self.org_id:
47
+ h["x-org-id"] = self.org_id
48
+ if extra:
49
+ h.update(extra)
50
+ return h
51
+
52
+ def url(self, path: str) -> str:
53
+ # ``base_url`` may or may not have a trailing slash; ``path`` must
54
+ # always start with one. Defensive join — httpx accepts both forms.
55
+ base = self.base_url.rstrip("/")
56
+ if not path.startswith("/"):
57
+ path = "/" + path
58
+ return base + path
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Request specs (built sync, dispatched by either client)
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class RequestSpec:
68
+ """A fully-built request — what the sync/async client dispatches."""
69
+
70
+ method: str
71
+ url: str
72
+ headers: Dict[str, str]
73
+ params: Optional[Dict[str, Any]]
74
+ json_body: Optional[Any]
75
+ timeout: float
76
+
77
+
78
+ def build_ingest_request(
79
+ cfg: TransportConfig,
80
+ contract_id: str,
81
+ events: Any,
82
+ *,
83
+ version: Optional[str] = None,
84
+ dry_run: bool = False,
85
+ atomic: bool = False,
86
+ timeout: Optional[float] = None,
87
+ ) -> RequestSpec:
88
+ """Build a POST to ``/ingest/{contract_id}[@version]``.
89
+
90
+ ``events`` may be a single dict (the gateway treats a non-array
91
+ body as a one-event batch) or a list. ``version`` becomes the
92
+ ``X-Contract-Version`` header so the gateway can resolve to a
93
+ specific pin (header > path-suffix > default-stable, per RFC-002).
94
+ """
95
+ path = f"/ingest/{contract_id}"
96
+ extra_headers: Dict[str, str] = {}
97
+ if version is not None:
98
+ # Header takes precedence over path-suffix per RFC-002. We use
99
+ # the header form by default — clients that need the @version
100
+ # path form for environments that strip headers can pass
101
+ # ``contract_id="<uuid>@<version>"`` and leave ``version=None``.
102
+ extra_headers["X-Contract-Version"] = version
103
+ params: Dict[str, Any] = {}
104
+ if dry_run:
105
+ params["dry_run"] = "true"
106
+ if atomic:
107
+ params["atomic"] = "true"
108
+ return RequestSpec(
109
+ method="POST",
110
+ url=cfg.url(path),
111
+ headers=cfg.headers(extra_headers),
112
+ params=params or None,
113
+ json_body=events,
114
+ timeout=timeout if timeout is not None else cfg.timeout,
115
+ )
116
+
117
+
118
+ def build_audit_request(
119
+ cfg: TransportConfig,
120
+ *,
121
+ contract_id: Optional[str] = None,
122
+ limit: int = 50,
123
+ offset: int = 0,
124
+ timeout: Optional[float] = None,
125
+ ) -> RequestSpec:
126
+ params: Dict[str, Any] = {"limit": str(limit), "offset": str(offset)}
127
+ if contract_id is not None:
128
+ params["contract_id"] = contract_id
129
+ return RequestSpec(
130
+ method="GET",
131
+ url=cfg.url("/audit"),
132
+ headers=cfg.headers(),
133
+ params=params,
134
+ json_body=None,
135
+ timeout=timeout if timeout is not None else cfg.timeout,
136
+ )
137
+
138
+
139
+ def build_get_contract_request(
140
+ cfg: TransportConfig,
141
+ contract_id: str,
142
+ *,
143
+ timeout: Optional[float] = None,
144
+ ) -> RequestSpec:
145
+ return RequestSpec(
146
+ method="GET",
147
+ url=cfg.url(f"/contracts/{contract_id}"),
148
+ headers=cfg.headers(),
149
+ params=None,
150
+ json_body=None,
151
+ timeout=timeout if timeout is not None else cfg.timeout,
152
+ )
153
+
154
+
155
+ def build_list_contracts_request(
156
+ cfg: TransportConfig,
157
+ *,
158
+ timeout: Optional[float] = None,
159
+ ) -> RequestSpec:
160
+ return RequestSpec(
161
+ method="GET",
162
+ url=cfg.url("/contracts"),
163
+ headers=cfg.headers(),
164
+ params=None,
165
+ json_body=None,
166
+ timeout=timeout if timeout is not None else cfg.timeout,
167
+ )
168
+
169
+
170
+ def build_get_version_request(
171
+ cfg: TransportConfig,
172
+ contract_id: str,
173
+ version: str,
174
+ *,
175
+ timeout: Optional[float] = None,
176
+ ) -> RequestSpec:
177
+ return RequestSpec(
178
+ method="GET",
179
+ url=cfg.url(f"/contracts/{contract_id}/versions/{version}"),
180
+ headers=cfg.headers(),
181
+ params=None,
182
+ json_body=None,
183
+ timeout=timeout if timeout is not None else cfg.timeout,
184
+ )
185
+
186
+
187
+ def build_list_versions_request(
188
+ cfg: TransportConfig,
189
+ contract_id: str,
190
+ *,
191
+ timeout: Optional[float] = None,
192
+ ) -> RequestSpec:
193
+ return RequestSpec(
194
+ method="GET",
195
+ url=cfg.url(f"/contracts/{contract_id}/versions"),
196
+ headers=cfg.headers(),
197
+ params=None,
198
+ json_body=None,
199
+ timeout=timeout if timeout is not None else cfg.timeout,
200
+ )
201
+
202
+
203
+ def build_latest_stable_request(
204
+ cfg: TransportConfig,
205
+ contract_id: str,
206
+ *,
207
+ timeout: Optional[float] = None,
208
+ ) -> RequestSpec:
209
+ return RequestSpec(
210
+ method="GET",
211
+ url=cfg.url(f"/contracts/{contract_id}/versions/latest-stable"),
212
+ headers=cfg.headers(),
213
+ params=None,
214
+ json_body=None,
215
+ timeout=timeout if timeout is not None else cfg.timeout,
216
+ )
217
+
218
+
219
+ def build_global_stats_request(
220
+ cfg: TransportConfig,
221
+ *,
222
+ timeout: Optional[float] = None,
223
+ ) -> RequestSpec:
224
+ return RequestSpec(
225
+ method="GET",
226
+ url=cfg.url("/stats"),
227
+ headers=cfg.headers(),
228
+ params=None,
229
+ json_body=None,
230
+ timeout=timeout if timeout is not None else cfg.timeout,
231
+ )
232
+
233
+
234
+ def build_playground_request(
235
+ cfg: TransportConfig,
236
+ yaml_content: str,
237
+ event: Any,
238
+ *,
239
+ timeout: Optional[float] = None,
240
+ ) -> RequestSpec:
241
+ return RequestSpec(
242
+ method="POST",
243
+ url=cfg.url("/playground/validate"),
244
+ headers=cfg.headers({"Content-Type": "application/json"}),
245
+ params=None,
246
+ json_body={"yaml_content": yaml_content, "event": event},
247
+ timeout=timeout if timeout is not None else cfg.timeout,
248
+ )
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Response decoding
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def decode_response(status: int, raw_body: bytes) -> Tuple[int, Any]:
257
+ """Decode the JSON body (best-effort) and raise on non-2xx.
258
+
259
+ Returns ``(status, decoded_body)``. ``decoded_body`` is whatever
260
+ JSON parsing produced; if parsing fails the raw text is returned
261
+ instead so the error message stays useful.
262
+ """
263
+ text = raw_body.decode("utf-8", errors="replace") if raw_body else ""
264
+ body: Any = None
265
+ if text:
266
+ try:
267
+ body = json.loads(text)
268
+ except json.JSONDecodeError:
269
+ body = text
270
+ raise_for_status(status, body)
271
+ return status, body
272
+
273
+
274
+ def expect_list(body: Any) -> List[Any]:
275
+ """Coerce a list response, defending against unexpected null."""
276
+ if body is None:
277
+ return []
278
+ if isinstance(body, list):
279
+ return body
280
+ raise ValueError(f"expected list response, got {type(body).__name__}")
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,205 @@
1
+ """Asynchronous HTTP client.
2
+
3
+ Thin wrapper over ``httpx.AsyncClient``. Mirrors :py:class:`Client`
4
+ method-for-method; the only difference is that every entry point is
5
+ ``async def`` and returns an awaitable.
6
+
7
+ Use as an async context manager so the underlying ``httpx.AsyncClient``
8
+ is closed cleanly:
9
+
10
+ async with AsyncClient(base_url=..., api_key=...) as cg:
11
+ result = await cg.ingest(contract_id="...", events=[...])
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, List, Optional
17
+
18
+ import httpx
19
+
20
+ from contractgate import _transport as _t
21
+ from contractgate.exceptions import ConnectionError as _ConnectionError
22
+ from contractgate.models import (
23
+ AuditEntry,
24
+ BatchIngestResponse,
25
+ ContractResponse,
26
+ IngestionStats,
27
+ VersionResponse,
28
+ VersionSummary,
29
+ )
30
+
31
+
32
+ class AsyncClient:
33
+ """Asynchronous client for the ContractGate gateway."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ base_url: str,
39
+ api_key: Optional[str] = None,
40
+ org_id: Optional[str] = None,
41
+ timeout: float = _t.DEFAULT_TIMEOUT_S,
42
+ transport: Optional[httpx.AsyncBaseTransport] = None,
43
+ ) -> None:
44
+ self._cfg = _t.TransportConfig(
45
+ base_url=base_url,
46
+ api_key=api_key,
47
+ org_id=org_id,
48
+ timeout=timeout,
49
+ )
50
+ self._http = httpx.AsyncClient(transport=transport, timeout=timeout)
51
+
52
+ # ------------------------------------------------------------------
53
+ # Async context-manager + cleanup
54
+ # ------------------------------------------------------------------
55
+
56
+ async def aclose(self) -> None:
57
+ await self._http.aclose()
58
+
59
+ async def __aenter__(self) -> "AsyncClient":
60
+ return self
61
+
62
+ async def __aexit__(self, exc_type, exc, tb) -> None:
63
+ await self.aclose()
64
+
65
+ # ------------------------------------------------------------------
66
+ # Ingest
67
+ # ------------------------------------------------------------------
68
+
69
+ async def ingest(
70
+ self,
71
+ *,
72
+ contract_id: str,
73
+ events: Any,
74
+ version: Optional[str] = None,
75
+ dry_run: bool = False,
76
+ atomic: bool = False,
77
+ timeout: Optional[float] = None,
78
+ ) -> BatchIngestResponse:
79
+ spec = _t.build_ingest_request(
80
+ self._cfg,
81
+ contract_id,
82
+ events,
83
+ version=version,
84
+ dry_run=dry_run,
85
+ atomic=atomic,
86
+ timeout=timeout,
87
+ )
88
+ _, body = await self._dispatch(spec)
89
+ return BatchIngestResponse.from_dict(body)
90
+
91
+ # ------------------------------------------------------------------
92
+ # Audit / stats
93
+ # ------------------------------------------------------------------
94
+
95
+ async def audit(
96
+ self,
97
+ *,
98
+ contract_id: Optional[str] = None,
99
+ limit: int = 50,
100
+ offset: int = 0,
101
+ timeout: Optional[float] = None,
102
+ ) -> List[AuditEntry]:
103
+ spec = _t.build_audit_request(
104
+ self._cfg,
105
+ contract_id=contract_id,
106
+ limit=limit,
107
+ offset=offset,
108
+ timeout=timeout,
109
+ )
110
+ _, body = await self._dispatch(spec)
111
+ return [AuditEntry.from_dict(r) for r in _t.expect_list(body)]
112
+
113
+ async def stats(self, *, timeout: Optional[float] = None) -> IngestionStats:
114
+ spec = _t.build_global_stats_request(self._cfg, timeout=timeout)
115
+ _, body = await self._dispatch(spec)
116
+ return IngestionStats.from_dict(body)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Contract reads
120
+ # ------------------------------------------------------------------
121
+
122
+ async def get_contract(
123
+ self,
124
+ contract_id: str,
125
+ *,
126
+ timeout: Optional[float] = None,
127
+ ) -> ContractResponse:
128
+ spec = _t.build_get_contract_request(self._cfg, contract_id, timeout=timeout)
129
+ _, body = await self._dispatch(spec)
130
+ return ContractResponse.from_dict(body)
131
+
132
+ async def list_contracts(
133
+ self, *, timeout: Optional[float] = None
134
+ ) -> List[ContractResponse]:
135
+ spec = _t.build_list_contracts_request(self._cfg, timeout=timeout)
136
+ _, body = await self._dispatch(spec)
137
+ return [ContractResponse.from_dict(r) for r in _t.expect_list(body)]
138
+
139
+ async def list_versions(
140
+ self,
141
+ contract_id: str,
142
+ *,
143
+ timeout: Optional[float] = None,
144
+ ) -> List[VersionSummary]:
145
+ spec = _t.build_list_versions_request(self._cfg, contract_id, timeout=timeout)
146
+ _, body = await self._dispatch(spec)
147
+ return [VersionSummary.from_dict(r) for r in _t.expect_list(body)]
148
+
149
+ async def get_version(
150
+ self,
151
+ contract_id: str,
152
+ version: str,
153
+ *,
154
+ timeout: Optional[float] = None,
155
+ ) -> VersionResponse:
156
+ spec = _t.build_get_version_request(
157
+ self._cfg, contract_id, version, timeout=timeout
158
+ )
159
+ _, body = await self._dispatch(spec)
160
+ return VersionResponse.from_dict(body)
161
+
162
+ async def get_latest_stable(
163
+ self,
164
+ contract_id: str,
165
+ *,
166
+ timeout: Optional[float] = None,
167
+ ) -> VersionResponse:
168
+ spec = _t.build_latest_stable_request(self._cfg, contract_id, timeout=timeout)
169
+ _, body = await self._dispatch(spec)
170
+ return VersionResponse.from_dict(body)
171
+
172
+ # ------------------------------------------------------------------
173
+ # Playground
174
+ # ------------------------------------------------------------------
175
+
176
+ async def playground_validate(
177
+ self,
178
+ *,
179
+ yaml_content: str,
180
+ event: Any,
181
+ timeout: Optional[float] = None,
182
+ ) -> Any:
183
+ spec = _t.build_playground_request(
184
+ self._cfg, yaml_content, event, timeout=timeout
185
+ )
186
+ _, body = await self._dispatch(spec)
187
+ return body
188
+
189
+ # ------------------------------------------------------------------
190
+ # Internals
191
+ # ------------------------------------------------------------------
192
+
193
+ async def _dispatch(self, spec: _t.RequestSpec):
194
+ try:
195
+ r = await self._http.request(
196
+ spec.method,
197
+ spec.url,
198
+ headers=spec.headers,
199
+ params=spec.params,
200
+ json=spec.json_body,
201
+ timeout=spec.timeout,
202
+ )
203
+ except httpx.HTTPError as e:
204
+ raise _ConnectionError(str(e)) from e
205
+ return _t.decode_response(r.status_code, r.content)