authgebra 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.
authgebra/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """Official Python client for the Authgebra ``/api/v1`` HTTP API.
2
+
3
+ A thin wrapper: it calls the server and interprets responses; it never evaluates
4
+ RBAC/ABAC/ReBAC itself. See ``README.md`` for semantics (results vs errors, retries, tracing).
5
+ """
6
+
7
+ from ._core import new_idempotency_key, new_traceparent, parse_traceparent
8
+ from ._generated.operations import OPERATIONS, SPEC_SHA256
9
+ from ._generated import models as api_models
10
+ from ._model import JSONValue, UNSET, Unset
11
+ from ._version import __version__
12
+ from .aclient import AsyncAuthorizationClient, AsyncClient
13
+ from .client import AuthorizationClient, Client
14
+ from .config import Config
15
+ from .errors import (
16
+ ApiError,
17
+ AuthgebraError,
18
+ CommitState,
19
+ CursorStaleError,
20
+ NotReadyError,
21
+ PaginationError,
22
+ ProtocolError,
23
+ RateLimitedError,
24
+ ReloadPendingError,
25
+ TransportError,
26
+ Violation,
27
+ )
28
+ from .models import (
29
+ ApiResult,
30
+ BatchCheckRequest,
31
+ BatchCheckResult,
32
+ BatchDecision,
33
+ CheckRequest,
34
+ CheckResult,
35
+ Decision,
36
+ ExplainRequest,
37
+ ExplainResult,
38
+ ListResourcesRequest,
39
+ Meta,
40
+ PageInfo,
41
+ ResourcePage,
42
+ ResourceRef,
43
+ ResponseInfo,
44
+ RetryPolicy,
45
+ RevisionResult,
46
+ )
47
+
48
+ __all__ = [
49
+ "api_models",
50
+ "JSONValue",
51
+ "UNSET",
52
+ "Unset",
53
+ "OPERATIONS",
54
+ "SPEC_SHA256",
55
+ "ApiError",
56
+ "ApiResult",
57
+ "AsyncAuthorizationClient",
58
+ "AsyncClient",
59
+ "AuthgebraError",
60
+ "AuthorizationClient",
61
+ "BatchCheckRequest",
62
+ "BatchCheckResult",
63
+ "BatchDecision",
64
+ "CheckRequest",
65
+ "CheckResult",
66
+ "Client",
67
+ "CommitState",
68
+ "Config",
69
+ "CursorStaleError",
70
+ "Decision",
71
+ "ExplainRequest",
72
+ "ExplainResult",
73
+ "ListResourcesRequest",
74
+ "Meta",
75
+ "NotReadyError",
76
+ "PageInfo",
77
+ "PaginationError",
78
+ "ProtocolError",
79
+ "RateLimitedError",
80
+ "ReloadPendingError",
81
+ "ResourcePage",
82
+ "ResourceRef",
83
+ "ResponseInfo",
84
+ "RetryPolicy",
85
+ "RevisionResult",
86
+ "TransportError",
87
+ "Violation",
88
+ "__version__",
89
+ "new_idempotency_key",
90
+ "new_traceparent",
91
+ "parse_traceparent",
92
+ ]
authgebra/_core.py ADDED
@@ -0,0 +1,477 @@
1
+ """Transport-independent protocol core shared by the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import re
8
+ import secrets
9
+ import ssl
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from typing import Any, Mapping, Protocol
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+
17
+ from ._generated.codes import KNOWN_ERROR_CODES
18
+ from .config import Config
19
+ from .errors import (
20
+ API_ERROR_CLASSES,
21
+ ApiError,
22
+ AuthgebraError,
23
+ CommitState,
24
+ ProtocolError,
25
+ TransportError,
26
+ Violation,
27
+ )
28
+ from .models import ApiResult, Meta, PageInfo, ResponseInfo, RetryPolicy
29
+
30
+ REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,128}$")
31
+ TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
32
+ IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
33
+ _HEX = re.compile(r"^[0-9a-f]+$")
34
+
35
+ RETRYABLE_CODES = frozenset({"RATE_LIMITED", "NOT_READY", "IDEMPOTENCY_IN_PROGRESS"})
36
+ _EXCERPT = 1024
37
+
38
+
39
+ # --------------------------------------------------------------------------- ids / tracing
40
+
41
+
42
+ def valid_request_id(value: str | None) -> str | None:
43
+ return value if value is not None and REQUEST_ID_RE.fullmatch(value) else None
44
+
45
+
46
+ def valid_trace_id(value: str | None) -> str | None:
47
+ if value is not None and TRACE_ID_RE.fullmatch(value) and value != "0" * 32:
48
+ return value
49
+ return None
50
+
51
+
52
+ def new_request_id() -> str:
53
+ return "py-" + uuid.uuid4().hex
54
+
55
+
56
+ def parse_traceparent(value: str) -> tuple[str, str, int] | None:
57
+ """Strict W3C parse; ``None`` for anything invalid (uppercase, all-zero, ``ff`` ...)."""
58
+ if len(value) < 55 or value[2] != "-" or value[35] != "-" or value[52] != "-":
59
+ return None
60
+ version, trace, span, flags = value[0:2], value[3:35], value[36:52], value[53:55]
61
+ if not (_HEX.fullmatch(version) and _HEX.fullmatch(trace) and _HEX.fullmatch(span)):
62
+ return None
63
+ if not _HEX.fullmatch(flags) or version == "ff":
64
+ return None
65
+ if version == "00" and len(value) != 55:
66
+ return None
67
+ if version != "00" and len(value) > 55 and value[55] != "-":
68
+ return None
69
+ if trace == "0" * 32 or span == "0" * 16:
70
+ return None
71
+ return trace, span, int(flags, 16)
72
+
73
+
74
+ def new_traceparent(trace_id: str | None = None, sampled: bool = False) -> str:
75
+ trace = trace_id or secrets.token_hex(16)
76
+ while trace == "0" * 32: # pragma: no cover - 2**-128
77
+ trace = secrets.token_hex(16)
78
+ span = secrets.token_hex(8)
79
+ while span == "0" * 16: # pragma: no cover - 2**-64
80
+ span = secrets.token_hex(8)
81
+ return f"00-{trace}-{span}-{'01' if sampled else '00'}"
82
+
83
+
84
+ def child_traceparent(traceparent: str) -> str:
85
+ """Same trace, new span id (each attempt is its own span)."""
86
+ parsed = parse_traceparent(traceparent)
87
+ if parsed is None:
88
+ return new_traceparent()
89
+ return new_traceparent(parsed[0], bool(parsed[2] & 1))
90
+
91
+
92
+ def resolve_traceparent(config: Config, explicit: str | None) -> str | None:
93
+ for candidate in (explicit, config.trace_provider() if config.trace_provider else None):
94
+ if candidate is not None and parse_traceparent(candidate) is not None:
95
+ return candidate
96
+ return new_traceparent() if config.auto_traceparent else None
97
+
98
+
99
+ def validate_idempotency_key(key: str | None) -> str | None:
100
+ if key is None:
101
+ return None
102
+ stripped = key.strip()
103
+ if not IDEMPOTENCY_KEY_RE.fullmatch(stripped):
104
+ raise ValueError("Idempotency-Key must be 1-128 chars of [A-Za-z0-9._-]")
105
+ return stripped
106
+
107
+
108
+ def new_idempotency_key() -> str:
109
+ """Convenience: a fresh, valid idempotency key. Reuse it for every resend of the same write."""
110
+ return uuid.uuid4().hex
111
+
112
+
113
+ # --------------------------------------------------------------------------- request building
114
+
115
+
116
+ def path_with(template: str, **params: object) -> str:
117
+ """Fill ``{name}`` segments with percent-encoded values."""
118
+ out = template
119
+ for name, value in params.items():
120
+ out = out.replace("{" + name + "}", quote(str(value), safe=""))
121
+ return out
122
+
123
+
124
+ def serialize_json(body: Any) -> bytes:
125
+ """Compact UTF-8 JSON; non-ASCII is written as-is (no ``\\u`` escapes), NaN is rejected."""
126
+ return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
127
+
128
+
129
+ QueryPairs = list[tuple[str, str | int | float | bool | None]]
130
+
131
+
132
+ def encode_query(query: Mapping[str, object] | None) -> QueryPairs:
133
+ pairs: QueryPairs = []
134
+ for name, value in (query or {}).items():
135
+ if value is None:
136
+ continue
137
+ if isinstance(value, bool):
138
+ pairs.append((name, "true" if value else "false"))
139
+ else:
140
+ pairs.append((name, str(value)))
141
+ return pairs
142
+
143
+
144
+ @dataclass
145
+ class Prepared:
146
+ method: str
147
+ url: str
148
+ params: QueryPairs
149
+ content: bytes | None
150
+ headers: dict[str, str]
151
+ safe: bool
152
+ has_key: bool
153
+ request_id: str | None
154
+
155
+
156
+ def prepare(
157
+ config: Config,
158
+ method: str,
159
+ path: str,
160
+ *,
161
+ query: Mapping[str, object] | None,
162
+ body: bytes | None,
163
+ token: str | None,
164
+ if_match: str | None,
165
+ idempotency_key: str | None,
166
+ request_id: str | None,
167
+ traceparent: str | None,
168
+ safe: bool,
169
+ ) -> Prepared:
170
+ from ._version import __version__
171
+
172
+ headers: dict[str, str] = {name: value for name, value in config.headers.items()}
173
+ ua = f"authgebra-python/{__version__}"
174
+ if config.user_agent_suffix:
175
+ ua += f" {config.user_agent_suffix}"
176
+ headers["User-Agent"] = ua
177
+ headers["Accept"] = "application/json"
178
+ if body is not None:
179
+ headers["Content-Type"] = "application/json"
180
+ if token:
181
+ headers["Authorization"] = f"Bearer {token}"
182
+ if if_match is not None:
183
+ headers["If-Match"] = if_match
184
+ key = validate_idempotency_key(idempotency_key)
185
+ if key is not None:
186
+ headers["Idempotency-Key"] = key
187
+ sent_request_id = valid_request_id(request_id)
188
+ if sent_request_id is None and config.auto_request_id:
189
+ sent_request_id = new_request_id()
190
+ if sent_request_id is not None:
191
+ headers["X-Request-ID"] = sent_request_id
192
+ if traceparent is not None:
193
+ headers["traceparent"] = traceparent
194
+ return Prepared(
195
+ method=method.upper(),
196
+ url=config.base_url.rstrip("/") + path,
197
+ params=encode_query(query),
198
+ content=body,
199
+ headers=headers,
200
+ safe=safe,
201
+ has_key=key is not None,
202
+ request_id=sent_request_id,
203
+ )
204
+
205
+
206
+ # --------------------------------------------------------------------------- transport errors
207
+
208
+
209
+ def _ssl_in_chain(exc: BaseException | None) -> bool:
210
+ seen = 0
211
+ while exc is not None and seen < 8:
212
+ if isinstance(exc, ssl.SSLError):
213
+ return True
214
+ exc = exc.__cause__ or exc.__context__
215
+ seen += 1
216
+ return False
217
+
218
+
219
+ def transport_error(exc: BaseException, prep: Prepared) -> TransportError:
220
+ if isinstance(exc, httpx.TimeoutException):
221
+ kind = "timeout"
222
+ elif isinstance(exc, httpx.ConnectError):
223
+ kind = "tls_error" if _ssl_in_chain(exc) else "connect_error"
224
+ elif isinstance(exc, (httpx.ReadError, httpx.WriteError, httpx.RemoteProtocolError, httpx.CloseError)):
225
+ kind = "connection_reset"
226
+ else:
227
+ kind = "transport"
228
+ return build_transport_error(kind, prep, type(exc).__name__)
229
+
230
+
231
+ def build_transport_error(kind: str, prep: Prepared, detail: str = "") -> TransportError:
232
+ # A failed write may or may not have been applied: never claim "not committed".
233
+ state = None if prep.safe else CommitState.UNKNOWN
234
+ return TransportError(kind, commit_state=state, request_id=prep.request_id, detail=detail)
235
+
236
+
237
+ # --------------------------------------------------------------------------- response parsing
238
+
239
+
240
+ @dataclass
241
+ class RawResponse:
242
+ status: int
243
+ headers: Mapping[str, str] # lower-cased names
244
+ body: bytes
245
+
246
+
247
+ def _reject_constant(name: str) -> Any:
248
+ raise ValueError(f"non-finite JSON number {name}")
249
+
250
+
251
+ def _excerpt(body: bytes) -> str:
252
+ return body[:_EXCERPT].decode("utf-8", errors="replace")
253
+
254
+
255
+ def _correlation(headers: Mapping[str, str]) -> tuple[str | None, str | None]:
256
+ header_rid = valid_request_id(headers.get("x-request-id"))
257
+ trace = valid_trace_id(headers.get("x-trace-id"))
258
+ if trace is None:
259
+ tp = headers.get("traceparent")
260
+ parsed = parse_traceparent(tp) if tp else None
261
+ trace = parsed[0] if parsed else None
262
+ return header_rid, trace
263
+
264
+
265
+ def _parse_meta(value: Any, raw: RawResponse, rid: str | None, trace: str | None) -> Meta | None:
266
+ if value is None:
267
+ return None
268
+
269
+ def bad() -> ProtocolError:
270
+ return ProtocolError(
271
+ "malformed_success_body", status=raw.status, request_id=rid, trace_id=trace,
272
+ excerpt=_excerpt(raw.body),
273
+ )
274
+
275
+ if not isinstance(value, dict):
276
+ raise bad()
277
+ revision = value.get("authz_revision")
278
+ generation = value.get("snapshot_generation")
279
+ if isinstance(revision, bool) or not isinstance(revision, int):
280
+ raise bad()
281
+ if generation is not None and (isinstance(generation, bool) or not isinstance(generation, int)):
282
+ raise bad()
283
+ return Meta(authz_revision=revision, snapshot_generation=generation)
284
+
285
+
286
+ def _parse_page(value: Any, raw: RawResponse, rid: str | None, trace: str | None) -> PageInfo | None:
287
+ if value is None:
288
+ return None
289
+ if (
290
+ isinstance(value, dict)
291
+ and isinstance(value.get("has_more"), bool)
292
+ and (value.get("next_cursor") is None or isinstance(value.get("next_cursor"), str))
293
+ ):
294
+ return PageInfo(next_cursor=value.get("next_cursor"), has_more=value["has_more"])
295
+ raise ProtocolError(
296
+ "malformed_success_body", status=raw.status, request_id=rid, trace_id=trace,
297
+ excerpt=_excerpt(raw.body),
298
+ )
299
+
300
+
301
+ def _load(raw: RawResponse, rid: str | None, trace: str | None) -> Any:
302
+ try:
303
+ return json.loads(raw.body.decode("utf-8"), parse_constant=_reject_constant)
304
+ except (UnicodeDecodeError, ValueError) as exc:
305
+ raise ProtocolError(
306
+ "non_json_response", status=raw.status, request_id=rid, trace_id=trace,
307
+ excerpt=_excerpt(raw.body),
308
+ ) from exc
309
+
310
+
311
+ def interpret(raw: RawResponse, *, envelope: bool = True) -> ApiResult:
312
+ """Turn one HTTP response into a result, or raise ``ApiError`` / ``ProtocolError``."""
313
+ header_rid, header_trace = _correlation(raw.headers)
314
+ if 200 <= raw.status < 300:
315
+ return _success(raw, envelope, header_rid, header_trace)
316
+ raise _error(raw, header_rid, header_trace)
317
+
318
+
319
+ def _success(raw: RawResponse, envelope: bool, rid: str | None, trace: str | None) -> ApiResult:
320
+ info = ResponseInfo(status=raw.status, request_id=rid, trace_id=trace)
321
+ etag = raw.headers.get("etag")
322
+ replayed = raw.headers.get("idempotency-replayed", "").lower() == "true"
323
+ revision_header: int | None = None
324
+ header_value = raw.headers.get("x-authz-revision")
325
+ if header_value is not None and header_value.isdigit():
326
+ revision_header = int(header_value)
327
+ if raw.status == 204 and not raw.body:
328
+ return ApiResult(None, info, None, None, etag, replayed, revision_header)
329
+ payload = _load(raw, rid, trace)
330
+ if not envelope:
331
+ return ApiResult(payload, info, None, None, etag, replayed, revision_header)
332
+ if not isinstance(payload, dict) or "data" not in payload:
333
+ raise ProtocolError(
334
+ "malformed_success_body", status=raw.status, request_id=rid, trace_id=trace,
335
+ excerpt=_excerpt(raw.body),
336
+ )
337
+ return ApiResult(
338
+ data=payload["data"],
339
+ info=info,
340
+ meta=_parse_meta(payload.get("meta"), raw, rid, trace),
341
+ page=_parse_page(payload.get("page"), raw, rid, trace),
342
+ etag=etag,
343
+ replayed=replayed,
344
+ authz_revision_header=revision_header,
345
+ )
346
+
347
+
348
+ def _error(raw: RawResponse, header_rid: str | None, header_trace: str | None) -> AuthgebraError:
349
+ payload = _load(raw, header_rid, header_trace)
350
+ err = payload.get("error") if isinstance(payload, dict) else None
351
+ code = err.get("code") if isinstance(err, dict) else None
352
+ if not isinstance(err, dict) or not isinstance(code, str) or not code:
353
+ return ProtocolError(
354
+ "malformed_error_body", status=raw.status, request_id=header_rid,
355
+ trace_id=header_trace, excerpt=_excerpt(raw.body),
356
+ )
357
+ raw_message = err.get("message")
358
+ message: str = raw_message if isinstance(raw_message, str) else ""
359
+ body_rid = valid_request_id(err.get("request_id") if isinstance(err.get("request_id"), str) else None)
360
+ request_id = header_rid or body_rid
361
+ mismatch = header_rid is not None and body_rid is not None and header_rid != body_rid
362
+ trace_value = err.get("trace_id")
363
+ trace = valid_trace_id(trace_value if isinstance(trace_value, str) else None) or header_trace
364
+
365
+ details = err.get("details") if isinstance(err.get("details"), dict) else None
366
+ violations: list[Violation] = []
367
+ if details is not None and isinstance(details.get("violations"), list):
368
+ for item in details["violations"]:
369
+ if isinstance(item, dict) and isinstance(item.get("reason"), str):
370
+ path = item.get("path")
371
+ text = item.get("message")
372
+ violations.append(
373
+ Violation(
374
+ path=path if isinstance(path, str) else None,
375
+ reason=item["reason"],
376
+ message=text if isinstance(text, str) else "",
377
+ )
378
+ )
379
+
380
+ retryable = err.get("retryable") if isinstance(err.get("retryable"), bool) else None
381
+ retry_after: int | None = None
382
+ header_retry = raw.headers.get("retry-after", "").strip()
383
+ body_retry = err.get("retry_after_seconds")
384
+ if header_retry.isdigit():
385
+ retry_after = int(header_retry)
386
+ elif isinstance(body_retry, int) and not isinstance(body_retry, bool) and body_retry >= 0:
387
+ retry_after = body_retry
388
+
389
+ commit: CommitState | None = None
390
+ commit_value = err.get("commit_state")
391
+ if isinstance(commit_value, str):
392
+ try:
393
+ commit = CommitState(commit_value)
394
+ except ValueError:
395
+ commit = CommitState.UNKNOWN
396
+
397
+ cls = API_ERROR_CLASSES.get(code, ApiError)
398
+ return cls(
399
+ status=raw.status,
400
+ code=code,
401
+ known_code=code in KNOWN_ERROR_CODES,
402
+ message=message,
403
+ request_id=request_id,
404
+ body_request_id=body_rid,
405
+ request_id_mismatch=mismatch,
406
+ request_id_header_valid=header_rid is not None,
407
+ trace_id=trace,
408
+ details=details,
409
+ violations=tuple(violations),
410
+ retryable=retryable,
411
+ retry_after_seconds=retry_after,
412
+ commit_state=commit,
413
+ raw_body_excerpt=_excerpt(raw.body),
414
+ )
415
+
416
+
417
+ # --------------------------------------------------------------------------- retry policy
418
+
419
+
420
+ def should_retry(
421
+ policy: RetryPolicy, *, safe: bool, has_key: bool, retries_done: int, error: BaseException
422
+ ) -> bool:
423
+ if retries_done >= policy.max_retries:
424
+ return False
425
+ if not safe and not (policy.retry_writes and has_key):
426
+ return False
427
+ if isinstance(error, ApiError):
428
+ if error.commit_state is CommitState.COMMITTED_UNPUBLISHED:
429
+ return False
430
+ return error.code in RETRYABLE_CODES
431
+ if isinstance(error, TransportError):
432
+ return error.error != "tls_error"
433
+ return False
434
+
435
+
436
+ def retry_delay(policy: RetryPolicy, retries_done: int, error: BaseException) -> float:
437
+ if isinstance(error, ApiError) and error.retry_after_seconds is not None:
438
+ return float(min(float(error.retry_after_seconds), policy.max_delay))
439
+ base = min(policy.base_delay * (2**retries_done), policy.max_delay)
440
+ return float(min(base * (1.0 + policy.jitter * random.random()), policy.max_delay))
441
+
442
+
443
+ # --------------------------------------------------------------------------- requester protocols
444
+
445
+
446
+ class SyncRequester(Protocol):
447
+ def request(
448
+ self,
449
+ method: str,
450
+ path: str,
451
+ *,
452
+ query: Mapping[str, object] | None = None,
453
+ json: Any = None,
454
+ if_match: str | None = None,
455
+ idempotency_key: str | None = None,
456
+ request_id: str | None = None,
457
+ traceparent: str | None = None,
458
+ safe: bool | None = None,
459
+ envelope: bool = True,
460
+ ) -> ApiResult: ...
461
+
462
+
463
+ class AsyncRequester(Protocol):
464
+ async def request(
465
+ self,
466
+ method: str,
467
+ path: str,
468
+ *,
469
+ query: Mapping[str, object] | None = None,
470
+ json: Any = None,
471
+ if_match: str | None = None,
472
+ idempotency_key: str | None = None,
473
+ request_id: str | None = None,
474
+ traceparent: str | None = None,
475
+ safe: bool | None = None,
476
+ envelope: bool = True,
477
+ ) -> ApiResult: ...
@@ -0,0 +1,2 @@
1
+ """GENERATED by tools/generate.py from sdks/contracts/openapi.json - do not edit."""
2
+