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 +92 -0
- authgebra/_core.py +477 -0
- authgebra/_generated/__init__.py +2 -0
- authgebra/_generated/aio.py +1535 -0
- authgebra/_generated/codes.py +29 -0
- authgebra/_generated/models.py +4041 -0
- authgebra/_generated/operations.py +108 -0
- authgebra/_generated/sync.py +1535 -0
- authgebra/_model.py +252 -0
- authgebra/_parse.py +158 -0
- authgebra/_version.py +3 -0
- authgebra/aclient.py +256 -0
- authgebra/client.py +272 -0
- authgebra/config.py +79 -0
- authgebra/errors.py +177 -0
- authgebra/models.py +263 -0
- authgebra/py.typed +0 -0
- authgebra-0.1.0.dist-info/METADATA +181 -0
- authgebra-0.1.0.dist-info/RECORD +22 -0
- authgebra-0.1.0.dist-info/WHEEL +5 -0
- authgebra-0.1.0.dist-info/licenses/LICENSE +42 -0
- authgebra-0.1.0.dist-info/top_level.txt +1 -0
authgebra/_model.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""JSON model codec shared by generated synchronous and asynchronous operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator, Mapping
|
|
9
|
+
from dataclasses import fields
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import ClassVar, TypeVar
|
|
12
|
+
|
|
13
|
+
from .errors import ProtocolError
|
|
14
|
+
from .models import ApiResult, JSONValue as JSONValue
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Unset(Enum):
|
|
18
|
+
"""A field absent from JSON; distinct from explicit JSON null."""
|
|
19
|
+
|
|
20
|
+
TOKEN = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
UNSET = Unset.TOKEN
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StringEnum(str, Enum):
|
|
27
|
+
"""Named wire enum that preserves a newer server's unknown string values."""
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def _missing_(cls, value: object) -> StringEnum | None:
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
return None
|
|
33
|
+
member = str.__new__(cls, value)
|
|
34
|
+
member._name_ = "UNKNOWN"
|
|
35
|
+
member._value_ = value
|
|
36
|
+
return member
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Model(Mapping[str, object]):
|
|
40
|
+
"""A generated named object; mapping access preserves existing dict consumers."""
|
|
41
|
+
|
|
42
|
+
__schema_pointer__: ClassVar[str]
|
|
43
|
+
__json_fields__: ClassVar[dict[str, str]]
|
|
44
|
+
__request_model__: ClassVar[bool] = False
|
|
45
|
+
|
|
46
|
+
def __getitem__(self, key: str) -> object:
|
|
47
|
+
name = self.__json_fields__.get(key)
|
|
48
|
+
if name is None:
|
|
49
|
+
raise KeyError(key)
|
|
50
|
+
value: object = getattr(self, name)
|
|
51
|
+
if isinstance(value, Unset):
|
|
52
|
+
raise KeyError(key)
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
def __iter__(self) -> Iterator[str]:
|
|
56
|
+
return (key for key, name in self.__json_fields__.items() if not isinstance(getattr(self, name), Unset))
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return sum(1 for _ in self)
|
|
60
|
+
|
|
61
|
+
def to_json(self) -> dict[str, JSONValue]:
|
|
62
|
+
return {key: json_value(self[key]) for key in self}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_json(cls: type[M], value: object) -> M:
|
|
66
|
+
parsed = decode(cls.__schema_pointer__, value, request=cls.__request_model__)
|
|
67
|
+
if not isinstance(parsed, cls):
|
|
68
|
+
raise ValueError(f"expected {cls.__name__}")
|
|
69
|
+
return parsed
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
M = TypeVar("M", bound=Model)
|
|
73
|
+
R = TypeVar("R", bound=ApiResult)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def json_value(value: object) -> JSONValue:
|
|
77
|
+
"""Convert only JSON values and named models, without a lossy floating-point hop."""
|
|
78
|
+
if isinstance(value, Unset):
|
|
79
|
+
raise ValueError("UNSET is only valid as an omitted object field")
|
|
80
|
+
if isinstance(value, Enum):
|
|
81
|
+
return json_value(value.value)
|
|
82
|
+
if isinstance(value, Model):
|
|
83
|
+
return value.to_json()
|
|
84
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
85
|
+
return value
|
|
86
|
+
if isinstance(value, float):
|
|
87
|
+
if not math.isfinite(value):
|
|
88
|
+
raise ValueError("JSON numbers must be finite")
|
|
89
|
+
return value
|
|
90
|
+
if isinstance(value, Mapping):
|
|
91
|
+
result: dict[str, JSONValue] = {}
|
|
92
|
+
for key, item in value.items():
|
|
93
|
+
if not isinstance(key, str):
|
|
94
|
+
raise ValueError("JSON object keys must be strings")
|
|
95
|
+
result[key] = json_value(item)
|
|
96
|
+
return result
|
|
97
|
+
if isinstance(value, (list, tuple)):
|
|
98
|
+
return [json_value(item) for item in value]
|
|
99
|
+
raise ValueError(f"unsupported JSON value type: {type(value).__name__}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def request_body(value: Model | Mapping[str, object] | None) -> JSONValue:
|
|
103
|
+
# The legacy dict path remains server-validated; generated model inputs validate locally.
|
|
104
|
+
if isinstance(value, Model):
|
|
105
|
+
raw = value.to_json()
|
|
106
|
+
decode(value.__schema_pointer__, raw, request=True)
|
|
107
|
+
return raw
|
|
108
|
+
return json_value(value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _schema(pointer: str) -> dict[str, object]:
|
|
112
|
+
from ._generated.models import SCHEMAS
|
|
113
|
+
|
|
114
|
+
return SCHEMAS[pointer]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _child(pointer: str, value: object, *, request: bool) -> object:
|
|
118
|
+
return decode(pointer, value, request=request)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def decode(pointer: str, value: object, *, request: bool = False) -> object:
|
|
122
|
+
"""Decode one schema location. Unknown response properties remain forward compatible."""
|
|
123
|
+
from ._generated.models import MODEL_TYPES, ENUM_TYPES
|
|
124
|
+
|
|
125
|
+
schema = _schema(pointer)
|
|
126
|
+
ref = schema.get("$ref")
|
|
127
|
+
if isinstance(ref, str):
|
|
128
|
+
return decode(ref, value, request=request)
|
|
129
|
+
for keyword in ("oneOf", "anyOf", "allOf"):
|
|
130
|
+
arms = schema.get(keyword)
|
|
131
|
+
if isinstance(arms, list):
|
|
132
|
+
if keyword == "allOf":
|
|
133
|
+
if len(arms) != 1:
|
|
134
|
+
raise ValueError(f"unsupported allOf at {pointer}")
|
|
135
|
+
return _child(f"{pointer}/{keyword}/0", value, request=request)
|
|
136
|
+
successes: list[object] = []
|
|
137
|
+
for index in range(len(arms)):
|
|
138
|
+
try:
|
|
139
|
+
successes.append(_child(f"{pointer}/{keyword}/{index}", value, request=request))
|
|
140
|
+
except ValueError:
|
|
141
|
+
pass
|
|
142
|
+
if not successes or (keyword == "oneOf" and len(successes) != 1):
|
|
143
|
+
raise ValueError(f"value does not match {keyword} at {pointer}")
|
|
144
|
+
return successes[0]
|
|
145
|
+
kind = schema.get("type")
|
|
146
|
+
if isinstance(kind, list):
|
|
147
|
+
if value is None and "null" in kind:
|
|
148
|
+
return None
|
|
149
|
+
kind = next((item for item in kind if item != "null"), None)
|
|
150
|
+
if value is None:
|
|
151
|
+
if kind == "null" or kind is None:
|
|
152
|
+
return None
|
|
153
|
+
raise ValueError(f"null is not accepted at {pointer}")
|
|
154
|
+
enum = schema.get("enum")
|
|
155
|
+
if isinstance(enum, list):
|
|
156
|
+
if value not in enum and (request or len(enum) == 1):
|
|
157
|
+
raise ValueError(f"unknown enum value at {pointer}")
|
|
158
|
+
enum_type = ENUM_TYPES.get(pointer)
|
|
159
|
+
return enum_type(value) if enum_type is not None else value
|
|
160
|
+
if kind == "string":
|
|
161
|
+
if not isinstance(value, str):
|
|
162
|
+
raise ValueError(f"string required at {pointer}")
|
|
163
|
+
if request:
|
|
164
|
+
minimum, maximum = schema.get("minLength"), schema.get("maxLength")
|
|
165
|
+
if isinstance(minimum, int) and len(value) < minimum:
|
|
166
|
+
raise ValueError(f"string too short at {pointer}")
|
|
167
|
+
if isinstance(maximum, int) and len(value) > maximum:
|
|
168
|
+
raise ValueError(f"string too long at {pointer}")
|
|
169
|
+
pattern = schema.get("pattern")
|
|
170
|
+
if isinstance(pattern, str) and re.fullmatch(pattern, value) is None:
|
|
171
|
+
raise ValueError(f"string pattern mismatch at {pointer}")
|
|
172
|
+
if schema.get("format") == "uuid":
|
|
173
|
+
try:
|
|
174
|
+
uuid.UUID(value)
|
|
175
|
+
except ValueError:
|
|
176
|
+
raise ValueError(f"UUID required at {pointer}") from None
|
|
177
|
+
return value
|
|
178
|
+
if kind in ("integer", "number"):
|
|
179
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or (kind == "integer" and not isinstance(value, int)):
|
|
180
|
+
raise ValueError(f"{kind} required at {pointer}")
|
|
181
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
182
|
+
raise ValueError(f"finite number required at {pointer}")
|
|
183
|
+
if request:
|
|
184
|
+
minimum, maximum = schema.get("minimum"), schema.get("maximum")
|
|
185
|
+
if isinstance(minimum, (int, float)) and value < minimum:
|
|
186
|
+
raise ValueError(f"number below minimum at {pointer}")
|
|
187
|
+
if isinstance(maximum, (int, float)) and value > maximum:
|
|
188
|
+
raise ValueError(f"number above maximum at {pointer}")
|
|
189
|
+
return value
|
|
190
|
+
if kind == "boolean":
|
|
191
|
+
if not isinstance(value, bool):
|
|
192
|
+
raise ValueError(f"boolean required at {pointer}")
|
|
193
|
+
return value
|
|
194
|
+
if kind == "array":
|
|
195
|
+
if not isinstance(value, (list, tuple)):
|
|
196
|
+
raise ValueError(f"array required at {pointer}")
|
|
197
|
+
if request:
|
|
198
|
+
minimum, maximum = schema.get("minItems"), schema.get("maxItems")
|
|
199
|
+
if isinstance(minimum, int) and len(value) < minimum:
|
|
200
|
+
raise ValueError(f"array too short at {pointer}")
|
|
201
|
+
if isinstance(maximum, int) and len(value) > maximum:
|
|
202
|
+
raise ValueError(f"array too long at {pointer}")
|
|
203
|
+
if schema.get("uniqueItems") is True:
|
|
204
|
+
import json
|
|
205
|
+
canonical = [json.dumps(json_value(item), sort_keys=True) for item in value]
|
|
206
|
+
if len(set(canonical)) != len(canonical):
|
|
207
|
+
raise ValueError(f"duplicate array items at {pointer}")
|
|
208
|
+
return [_child(f"{pointer}/items", item, request=request) for item in value]
|
|
209
|
+
if kind == "object" or "properties" in schema:
|
|
210
|
+
if not isinstance(value, Mapping):
|
|
211
|
+
raise ValueError(f"object required at {pointer}")
|
|
212
|
+
if any(not isinstance(key, str) for key in value):
|
|
213
|
+
raise ValueError(f"string object keys required at {pointer}")
|
|
214
|
+
props = schema.get("properties")
|
|
215
|
+
if isinstance(props, dict):
|
|
216
|
+
required = schema.get("required", [])
|
|
217
|
+
if not isinstance(required, list):
|
|
218
|
+
raise ValueError(f"invalid required metadata at {pointer}")
|
|
219
|
+
if any(key not in value for key in required):
|
|
220
|
+
raise ValueError(f"required field absent at {pointer}")
|
|
221
|
+
if request and schema.get("additionalProperties") is False and any(key not in props for key in value):
|
|
222
|
+
raise ValueError(f"unknown field at {pointer}")
|
|
223
|
+
cls = MODEL_TYPES[pointer]
|
|
224
|
+
params: dict[str, object] = {}
|
|
225
|
+
for key, name in cls.__json_fields__.items():
|
|
226
|
+
if key in value:
|
|
227
|
+
escaped = key.replace("~", "~0").replace("/", "~1")
|
|
228
|
+
params[name] = _child(f"{pointer}/properties/{escaped}", value[key], request=request)
|
|
229
|
+
return cls(**params)
|
|
230
|
+
additional = schema.get("additionalProperties")
|
|
231
|
+
if isinstance(additional, dict):
|
|
232
|
+
return {key: _child(f"{pointer}/additionalProperties", item, request=request) for key, item in value.items()}
|
|
233
|
+
return json_value(value)
|
|
234
|
+
if kind == "null":
|
|
235
|
+
raise ValueError(f"null required at {pointer}")
|
|
236
|
+
if kind is None:
|
|
237
|
+
return json_value(value)
|
|
238
|
+
raise ValueError(f"unsupported schema type at {pointer}: {kind}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def typed_result(result: ApiResult, cls: type[R], pointer: str) -> R:
|
|
242
|
+
try:
|
|
243
|
+
data = decode(pointer, result.data)
|
|
244
|
+
except ValueError as exc:
|
|
245
|
+
raise ProtocolError(
|
|
246
|
+
"malformed_success_body", status=result.info.status,
|
|
247
|
+
request_id=result.info.request_id, trace_id=result.info.trace_id,
|
|
248
|
+
excerpt=str(exc),
|
|
249
|
+
) from None
|
|
250
|
+
values = {field.name: getattr(result, field.name) for field in fields(ApiResult)}
|
|
251
|
+
values["data"] = data
|
|
252
|
+
return cls(**values)
|
authgebra/_parse.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Typed parsing of the authorization endpoints' ``data`` payloads (shared by sync and async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .errors import ProtocolError
|
|
8
|
+
from ._generated import models as api_models
|
|
9
|
+
from .models import (
|
|
10
|
+
ApiResult,
|
|
11
|
+
BatchCheckResult,
|
|
12
|
+
BatchDecision,
|
|
13
|
+
CheckResult,
|
|
14
|
+
Decision,
|
|
15
|
+
ExplainResult,
|
|
16
|
+
ResourcePage,
|
|
17
|
+
ResourceRef,
|
|
18
|
+
RevisionResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bad(result: ApiResult, what: str) -> ProtocolError:
|
|
23
|
+
return ProtocolError(
|
|
24
|
+
"malformed_success_body",
|
|
25
|
+
status=result.info.status,
|
|
26
|
+
request_id=result.info.request_id,
|
|
27
|
+
trace_id=result.info.trace_id,
|
|
28
|
+
excerpt=what,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _decision(result: ApiResult, value: Any) -> Decision:
|
|
33
|
+
try:
|
|
34
|
+
return Decision(value)
|
|
35
|
+
except ValueError:
|
|
36
|
+
# Fail closed: an unrecognised decision is never interpreted as allow (or deny).
|
|
37
|
+
raise ProtocolError(
|
|
38
|
+
"unknown_decision",
|
|
39
|
+
status=result.info.status,
|
|
40
|
+
request_id=result.info.request_id,
|
|
41
|
+
trace_id=result.info.trace_id,
|
|
42
|
+
) from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _obj(result: ApiResult, value: Any, what: str) -> Mapping[str, Any]:
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise _bad(result, what)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _str(result: ApiResult, obj: Mapping[str, Any], key: str) -> str:
|
|
52
|
+
value = obj.get(key)
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise _bad(result, f"{key} must be a string")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _int(result: ApiResult, obj: Mapping[str, Any], key: str) -> int:
|
|
59
|
+
value = obj.get(key)
|
|
60
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
61
|
+
raise _bad(result, f"{key} must be an integer")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _policy(result: ApiResult, obj: Mapping[str, Any]) -> api_models.AuthzPolicyRef | None:
|
|
66
|
+
value = obj.get("matched_policy")
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
return api_models.AuthzPolicyRef.from_json(value)
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise _bad(result, "matched_policy") from None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resource_ref(result: ApiResult, value: Any) -> ResourceRef:
|
|
76
|
+
obj = _obj(result, value, "resource")
|
|
77
|
+
return ResourceRef(_str(result, obj, "resource_type_code"), _str(result, obj, "external_id"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check(result: ApiResult) -> CheckResult:
|
|
81
|
+
data = _obj(result, result.data, "data")
|
|
82
|
+
return CheckResult(
|
|
83
|
+
decision=_decision(result, data.get("decision")),
|
|
84
|
+
reason_code=_str(result, data, "reason_code"),
|
|
85
|
+
matched_policy=_policy(result, data),
|
|
86
|
+
info=result.info,
|
|
87
|
+
meta=result.meta,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _batch_items(result: ApiResult, value: Any) -> tuple[BatchDecision, ...]:
|
|
92
|
+
if not isinstance(value, list):
|
|
93
|
+
raise _bad(result, "decision list expected")
|
|
94
|
+
items = []
|
|
95
|
+
for entry in value:
|
|
96
|
+
obj = _obj(result, entry, "batch item")
|
|
97
|
+
items.append(
|
|
98
|
+
BatchDecision(
|
|
99
|
+
resource=resource_ref(result, obj.get("resource")),
|
|
100
|
+
reason_code=_str(result, obj, "reason_code"),
|
|
101
|
+
matched_policy=_policy(result, obj),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return tuple(items)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def batch(result: ApiResult) -> BatchCheckResult:
|
|
108
|
+
data = _obj(result, result.data, "data")
|
|
109
|
+
return BatchCheckResult(
|
|
110
|
+
allowed=_batch_items(result, data.get("allowed")),
|
|
111
|
+
denied=_batch_items(result, data.get("denied")),
|
|
112
|
+
info=result.info,
|
|
113
|
+
meta=result.meta,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resource_page(result: ApiResult) -> ResourcePage:
|
|
118
|
+
if not isinstance(result.data, list) or result.page is None:
|
|
119
|
+
raise _bad(result, "paged list expected")
|
|
120
|
+
return ResourcePage(
|
|
121
|
+
items=tuple(resource_ref(result, entry) for entry in result.data),
|
|
122
|
+
page=result.page,
|
|
123
|
+
info=result.info,
|
|
124
|
+
meta=result.meta,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def explain(result: ApiResult) -> ExplainResult:
|
|
129
|
+
data = _obj(result, result.data, "data")
|
|
130
|
+
policies = data.get("policies")
|
|
131
|
+
truncated = data.get("truncated")
|
|
132
|
+
if not isinstance(policies, list) or not isinstance(truncated, bool):
|
|
133
|
+
raise _bad(result, "policies/truncated")
|
|
134
|
+
try:
|
|
135
|
+
explanations = tuple(api_models.AuthzPolicyExplanation.from_json(policy) for policy in policies)
|
|
136
|
+
except ValueError:
|
|
137
|
+
raise _bad(result, "policies") from None
|
|
138
|
+
return ExplainResult(
|
|
139
|
+
decision=_decision(result, data.get("decision")),
|
|
140
|
+
reason_code=_str(result, data, "reason_code"),
|
|
141
|
+
matched_policy=_policy(result, data),
|
|
142
|
+
policies=explanations,
|
|
143
|
+
truncated=truncated,
|
|
144
|
+
total_steps=_int(result, data, "total_steps"),
|
|
145
|
+
info=result.info,
|
|
146
|
+
meta=result.meta,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def revision(result: ApiResult) -> RevisionResult:
|
|
151
|
+
data = _obj(result, result.data, "data")
|
|
152
|
+
return RevisionResult(
|
|
153
|
+
db_revision=_int(result, data, "db_revision"),
|
|
154
|
+
snapshot_generation=_int(result, data, "snapshot_generation"),
|
|
155
|
+
status=_str(result, data, "status"),
|
|
156
|
+
info=result.info,
|
|
157
|
+
meta=result.meta,
|
|
158
|
+
)
|
authgebra/_version.py
ADDED
authgebra/aclient.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Asynchronous client (``asyncio``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, AsyncIterator, Mapping
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from . import _core, _parse
|
|
15
|
+
from ._generated.aio import AsyncAdminClient
|
|
16
|
+
from .client import DEFAULT_MAX_PAGES
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .errors import ApiError, PaginationError, ProtocolError, TransportError
|
|
19
|
+
from .models import (
|
|
20
|
+
ApiResult,
|
|
21
|
+
BatchCheckRequest,
|
|
22
|
+
BatchCheckResult,
|
|
23
|
+
CheckRequest,
|
|
24
|
+
CheckResult,
|
|
25
|
+
ExplainRequest,
|
|
26
|
+
ExplainResult,
|
|
27
|
+
ListResourcesRequest,
|
|
28
|
+
ResourcePage,
|
|
29
|
+
RevisionResult,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("authgebra")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncAuthorizationClient:
|
|
36
|
+
"""Async twin of :class:`~authgebra.client.AuthorizationClient` (identical semantics)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
39
|
+
self._c = client
|
|
40
|
+
|
|
41
|
+
async def check(
|
|
42
|
+
self, request: CheckRequest, *, request_id: str | None = None, traceparent: str | None = None
|
|
43
|
+
) -> CheckResult:
|
|
44
|
+
return _parse.check(await self._post("/api/v1/authz/check", request.to_json(), request_id, traceparent))
|
|
45
|
+
|
|
46
|
+
async def batch_check(
|
|
47
|
+
self, request: BatchCheckRequest, *, request_id: str | None = None, traceparent: str | None = None
|
|
48
|
+
) -> BatchCheckResult:
|
|
49
|
+
return _parse.batch(
|
|
50
|
+
await self._post("/api/v1/authz/batch-check", request.to_json(), request_id, traceparent)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def explain(
|
|
54
|
+
self, request: ExplainRequest, *, request_id: str | None = None, traceparent: str | None = None
|
|
55
|
+
) -> ExplainResult:
|
|
56
|
+
return _parse.explain(
|
|
57
|
+
await self._post("/api/v1/authz/explain", request.to_json(), request_id, traceparent)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def list_resources(
|
|
61
|
+
self,
|
|
62
|
+
request: ListResourcesRequest,
|
|
63
|
+
*,
|
|
64
|
+
request_id: str | None = None,
|
|
65
|
+
traceparent: str | None = None,
|
|
66
|
+
) -> ResourcePage:
|
|
67
|
+
return _parse.resource_page(
|
|
68
|
+
await self._post("/api/v1/authz/list-resources", request.to_json(), request_id, traceparent)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def iter_list_resources(
|
|
72
|
+
self,
|
|
73
|
+
request: ListResourcesRequest,
|
|
74
|
+
*,
|
|
75
|
+
max_pages: int = DEFAULT_MAX_PAGES,
|
|
76
|
+
traceparent: str | None = None,
|
|
77
|
+
) -> AsyncIterator[ResourcePage]:
|
|
78
|
+
"""Async page iterator: lazy (backpressure), cancellable, bounded, never stitches generations."""
|
|
79
|
+
if max_pages < 1:
|
|
80
|
+
raise ValueError("max_pages must be >= 1")
|
|
81
|
+
trace = _core.resolve_traceparent(self._c.config, traceparent)
|
|
82
|
+
current = request
|
|
83
|
+
generation: int | None = None
|
|
84
|
+
seen: set[str] = set()
|
|
85
|
+
for _ in range(max_pages):
|
|
86
|
+
page = await self.list_resources(current, traceparent=trace)
|
|
87
|
+
gen = page.meta.snapshot_generation if page.meta else None
|
|
88
|
+
if generation is None:
|
|
89
|
+
generation = gen
|
|
90
|
+
elif gen is not None and gen != generation:
|
|
91
|
+
raise PaginationError(
|
|
92
|
+
f"snapshot generation changed during paging ({generation} -> {gen}); restart explicitly"
|
|
93
|
+
)
|
|
94
|
+
yield page
|
|
95
|
+
cursor = page.page.next_cursor
|
|
96
|
+
if not page.page.has_more or cursor is None:
|
|
97
|
+
return
|
|
98
|
+
if cursor in seen:
|
|
99
|
+
raise PaginationError("server returned a repeated cursor; refusing to loop")
|
|
100
|
+
seen.add(cursor)
|
|
101
|
+
current = request.with_cursor(cursor)
|
|
102
|
+
raise PaginationError(f"page limit of {max_pages} reached while more pages remain")
|
|
103
|
+
|
|
104
|
+
async def revision(
|
|
105
|
+
self, *, request_id: str | None = None, traceparent: str | None = None
|
|
106
|
+
) -> RevisionResult:
|
|
107
|
+
return _parse.revision(
|
|
108
|
+
await self._c.request(
|
|
109
|
+
"GET", "/api/v1/authz/revision", request_id=request_id, traceparent=traceparent
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def _post(
|
|
114
|
+
self, path: str, body: Mapping[str, Any], request_id: str | None, traceparent: str | None
|
|
115
|
+
) -> ApiResult:
|
|
116
|
+
return await self._c.request(
|
|
117
|
+
"POST", path, json=body, safe=True, request_id=request_id, traceparent=traceparent
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncClient:
|
|
122
|
+
"""Asynchronous Authgebra client; use ``async with``.
|
|
123
|
+
|
|
124
|
+
Cancelling the awaiting task cancels the in-flight request; no retry is attempted after a
|
|
125
|
+
cancellation.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, config: Config) -> None:
|
|
129
|
+
self.config = config
|
|
130
|
+
self._owns_http = config.http_client is None
|
|
131
|
+
self._http: httpx.AsyncClient = config.http_client or httpx.AsyncClient(
|
|
132
|
+
verify=config.verify,
|
|
133
|
+
transport=config.transport,
|
|
134
|
+
follow_redirects=False,
|
|
135
|
+
trust_env=False,
|
|
136
|
+
)
|
|
137
|
+
self.authorization = AsyncAuthorizationClient(self)
|
|
138
|
+
self.admin = AsyncAdminClient(self)
|
|
139
|
+
|
|
140
|
+
async def __aenter__(self) -> AsyncClient:
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
async def __aexit__(
|
|
144
|
+
self,
|
|
145
|
+
exc_type: type[BaseException] | None,
|
|
146
|
+
exc: BaseException | None,
|
|
147
|
+
tb: TracebackType | None,
|
|
148
|
+
) -> None:
|
|
149
|
+
await self.aclose()
|
|
150
|
+
|
|
151
|
+
async def aclose(self) -> None:
|
|
152
|
+
if self._owns_http:
|
|
153
|
+
await self._http.aclose()
|
|
154
|
+
|
|
155
|
+
async def request(
|
|
156
|
+
self,
|
|
157
|
+
method: str,
|
|
158
|
+
path: str,
|
|
159
|
+
*,
|
|
160
|
+
query: Mapping[str, object] | None = None,
|
|
161
|
+
json: Any = None,
|
|
162
|
+
if_match: str | None = None,
|
|
163
|
+
idempotency_key: str | None = None,
|
|
164
|
+
request_id: str | None = None,
|
|
165
|
+
traceparent: str | None = None,
|
|
166
|
+
safe: bool | None = None,
|
|
167
|
+
envelope: bool = True,
|
|
168
|
+
) -> ApiResult:
|
|
169
|
+
cfg = self.config
|
|
170
|
+
is_safe = method.upper() in ("GET", "HEAD") if safe is None else safe
|
|
171
|
+
body = None if json is None else _core.serialize_json(json)
|
|
172
|
+
trace = _core.resolve_traceparent(cfg, traceparent)
|
|
173
|
+
retries_done = 0
|
|
174
|
+
while True:
|
|
175
|
+
attempt_trace = trace if trace is None or retries_done == 0 else _core.child_traceparent(trace)
|
|
176
|
+
prep = _core.prepare(
|
|
177
|
+
cfg,
|
|
178
|
+
method,
|
|
179
|
+
path,
|
|
180
|
+
query=query,
|
|
181
|
+
body=body,
|
|
182
|
+
token=await self._token(),
|
|
183
|
+
if_match=if_match,
|
|
184
|
+
idempotency_key=idempotency_key,
|
|
185
|
+
request_id=request_id,
|
|
186
|
+
traceparent=attempt_trace,
|
|
187
|
+
safe=is_safe,
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
return _core.interpret(await self._send(prep), envelope=envelope)
|
|
191
|
+
except (ApiError, TransportError, ProtocolError) as exc:
|
|
192
|
+
exc.attempts = retries_done + 1
|
|
193
|
+
if not _core.should_retry(
|
|
194
|
+
cfg.retry,
|
|
195
|
+
safe=prep.safe,
|
|
196
|
+
has_key=prep.has_key,
|
|
197
|
+
retries_done=retries_done,
|
|
198
|
+
error=exc,
|
|
199
|
+
):
|
|
200
|
+
raise
|
|
201
|
+
delay = _core.retry_delay(cfg.retry, retries_done, exc)
|
|
202
|
+
log.debug("retrying %s %s in %.3fs (attempt %d)", prep.method, path, delay, retries_done + 2)
|
|
203
|
+
await (cfg.retry.async_sleep or asyncio.sleep)(delay)
|
|
204
|
+
retries_done += 1
|
|
205
|
+
|
|
206
|
+
async def _token(self) -> str | None:
|
|
207
|
+
provider = self.config.token_provider
|
|
208
|
+
if provider is None:
|
|
209
|
+
return None
|
|
210
|
+
token = provider()
|
|
211
|
+
if inspect.isawaitable(token):
|
|
212
|
+
token = await token
|
|
213
|
+
if token is not None and not isinstance(token, str):
|
|
214
|
+
raise TypeError("token_provider must return str or None")
|
|
215
|
+
return token
|
|
216
|
+
|
|
217
|
+
async def _send(self, prep: _core.Prepared) -> _core.RawResponse:
|
|
218
|
+
cfg = self.config
|
|
219
|
+
try:
|
|
220
|
+
return await asyncio.wait_for(self._attempt(prep), timeout=cfg.timeout)
|
|
221
|
+
except asyncio.TimeoutError:
|
|
222
|
+
raise _core.build_transport_error("timeout", prep, "deadline exceeded") from None
|
|
223
|
+
|
|
224
|
+
async def _attempt(self, prep: _core.Prepared) -> _core.RawResponse:
|
|
225
|
+
cfg = self.config
|
|
226
|
+
timeout = httpx.Timeout(
|
|
227
|
+
connect=min(cfg.connect_timeout, cfg.timeout), read=cfg.timeout, write=cfg.timeout, pool=cfg.timeout
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
async with self._http.stream(
|
|
231
|
+
prep.method,
|
|
232
|
+
prep.url,
|
|
233
|
+
params=prep.params or None,
|
|
234
|
+
content=prep.content,
|
|
235
|
+
headers=prep.headers,
|
|
236
|
+
timeout=timeout,
|
|
237
|
+
) as response:
|
|
238
|
+
declared = response.headers.get("content-length")
|
|
239
|
+
if declared is not None and declared.isdigit() and int(declared) > cfg.max_response_bytes:
|
|
240
|
+
raise ProtocolError("response_too_large", status=response.status_code, request_id=prep.request_id)
|
|
241
|
+
chunks: list[bytes] = []
|
|
242
|
+
size = 0
|
|
243
|
+
async for chunk in response.aiter_bytes():
|
|
244
|
+
size += len(chunk)
|
|
245
|
+
if size > cfg.max_response_bytes:
|
|
246
|
+
raise ProtocolError(
|
|
247
|
+
"response_too_large", status=response.status_code, request_id=prep.request_id
|
|
248
|
+
)
|
|
249
|
+
chunks.append(chunk)
|
|
250
|
+
headers = {name.lower(): value for name, value in response.headers.items()}
|
|
251
|
+
log.debug("%s %s -> %d", prep.method, prep.url.split("?", 1)[0], response.status_code)
|
|
252
|
+
return _core.RawResponse(response.status_code, headers, b"".join(chunks))
|
|
253
|
+
except (ProtocolError, TransportError):
|
|
254
|
+
raise
|
|
255
|
+
except httpx.HTTPError as exc:
|
|
256
|
+
raise _core.transport_error(exc, prep) from exc
|