pierre-sdk 0.1.0.dev4__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.
- pierre/__init__.py +59 -0
- pierre/_client.py +544 -0
- pierre/_compat.py +14 -0
- pierre/_content.py +168 -0
- pierre/_credentials.py +321 -0
- pierre/_errors.py +205 -0
- pierre/_generated/__init__.py +2 -0
- pierre/_generated/client.py +54 -0
- pierre/_generated/headers.py +69 -0
- pierre/_generated/namespaces/__init__.py +2 -0
- pierre/_generated/namespaces/meta.py +125 -0
- pierre/_generated/namespaces/repos.py +5658 -0
- pierre/_generated/schemas.py +1437 -0
- pierre/_generated/scopes.py +12 -0
- pierre/_generated/webhooks.py +30 -0
- pierre/_helpers/__init__.py +1 -0
- pierre/_helpers/commits.py +220 -0
- pierre/_helpers/repos.py +58 -0
- pierre/_operation.py +66 -0
- pierre/_page.py +154 -0
- pierre/_response.py +92 -0
- pierre/_retry.py +65 -0
- pierre/_runtime.py +763 -0
- pierre/_types.py +73 -0
- pierre/_version.py +3 -0
- pierre/auth.py +84 -0
- pierre/git.py +161 -0
- pierre/py.typed +0 -0
- pierre/types.py +14 -0
- pierre/webhooks.py +145 -0
- pierre_sdk-0.1.0.dev4.dist-info/METADATA +153 -0
- pierre_sdk-0.1.0.dev4.dist-info/RECORD +34 -0
- pierre_sdk-0.1.0.dev4.dist-info/WHEEL +4 -0
- pierre_sdk-0.1.0.dev4.dist-info/licenses/LICENSE +189 -0
pierre/_runtime.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""The request functions: they turn an operation spec and the arguments of a call into an HTTP
|
|
2
|
+
request, and the response into the result of its encoding. They apply the timeout and the retries.
|
|
3
|
+
`SyncRuntime` serves `Pierre`, and `AsyncRuntime` serves `AsyncPierre`."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from itertools import count
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
AsyncIterable,
|
|
14
|
+
AsyncIterator,
|
|
15
|
+
Dict,
|
|
16
|
+
Iterable,
|
|
17
|
+
Iterator,
|
|
18
|
+
List,
|
|
19
|
+
Mapping,
|
|
20
|
+
Optional,
|
|
21
|
+
Sequence,
|
|
22
|
+
Tuple,
|
|
23
|
+
Union,
|
|
24
|
+
)
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from pierre import _retry
|
|
30
|
+
from pierre._credentials import (
|
|
31
|
+
REQUEST_TOKEN_SECONDS,
|
|
32
|
+
Credentials,
|
|
33
|
+
Rules,
|
|
34
|
+
TokenNeeds,
|
|
35
|
+
check_ref_policies,
|
|
36
|
+
check_ttl,
|
|
37
|
+
copy_ref_policies,
|
|
38
|
+
)
|
|
39
|
+
from pierre._errors import (
|
|
40
|
+
APIConnectionError,
|
|
41
|
+
APITimeoutError,
|
|
42
|
+
PierreError,
|
|
43
|
+
api_error,
|
|
44
|
+
problem_code,
|
|
45
|
+
)
|
|
46
|
+
from pierre._operation import JsonBodySpec, NdjsonBodySpec, OperationSpec
|
|
47
|
+
from pierre._page import AsyncPage, AsyncPaginator, Page
|
|
48
|
+
from pierre._response import RawResponse, claim_raw
|
|
49
|
+
from pierre._types import NotGiven, RequestOptions
|
|
50
|
+
|
|
51
|
+
# A repeat of these methods is safe.
|
|
52
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
|
|
53
|
+
|
|
54
|
+
# The largest `Idempotency-Key`, in UTF-8 bytes.
|
|
55
|
+
_MAX_IDEMPOTENCY_KEY_BYTES = 255
|
|
56
|
+
|
|
57
|
+
# The characters that `encodeURIComponent` keeps, so that a path is the same in every SDK.
|
|
58
|
+
_SEGMENT_SAFE = "!~*'()"
|
|
59
|
+
|
|
60
|
+
Values = Dict[str, Any]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class HttpConfig:
|
|
65
|
+
"""The settings of the transport of a client."""
|
|
66
|
+
|
|
67
|
+
base_url: str
|
|
68
|
+
credentials: Credentials
|
|
69
|
+
# The `Pierre-Client` header.
|
|
70
|
+
client: str
|
|
71
|
+
default_headers: Mapping[str, str]
|
|
72
|
+
# The timeout of each attempt, in seconds.
|
|
73
|
+
timeout: float
|
|
74
|
+
max_retries: int
|
|
75
|
+
# The ref policies of the client, which a request option replaces.
|
|
76
|
+
rules: Optional[Rules]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_timeout(timeout: object) -> float:
|
|
80
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not timeout > 0:
|
|
81
|
+
raise PierreError("`timeout` must be a number of seconds greater than 0.")
|
|
82
|
+
return float(timeout)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_max_retries(max_retries: object) -> int:
|
|
86
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
87
|
+
raise PierreError("`max_retries` must be a whole number, 0 or more.")
|
|
88
|
+
return max_retries
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def copy_options(options: RequestOptions) -> RequestOptions:
|
|
92
|
+
"""A copy of the request options, their headers, and their ref policies, so that a later
|
|
93
|
+
change by the caller does not change a request or a next page."""
|
|
94
|
+
headers = options.extra_headers
|
|
95
|
+
policies = options.ref_policies
|
|
96
|
+
return RequestOptions(
|
|
97
|
+
extra_headers=dict(headers) if isinstance(headers, Mapping) else headers,
|
|
98
|
+
timeout=options.timeout,
|
|
99
|
+
max_retries=options.max_retries,
|
|
100
|
+
idempotency_key=options.idempotency_key,
|
|
101
|
+
token_ttl=options.token_ttl,
|
|
102
|
+
ref_policies=policies if isinstance(policies, NotGiven) else copy_ref_policies(policies), # type: ignore[arg-type]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _given(value: Any, default: Any) -> Any:
|
|
107
|
+
return default if isinstance(value, NotGiven) else value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class BodyFailure:
|
|
112
|
+
"""The first error of the body content, for example from a commit file's content function."""
|
|
113
|
+
|
|
114
|
+
error: Optional[BaseException] = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Prepared:
|
|
119
|
+
"""The parts of a call that each attempt uses."""
|
|
120
|
+
|
|
121
|
+
operation: OperationSpec[Any]
|
|
122
|
+
url: httpx.URL
|
|
123
|
+
# The headers without the token and the request headers.
|
|
124
|
+
headers: List[Tuple[str, str]]
|
|
125
|
+
json: Optional[bytes]
|
|
126
|
+
# The lines of an NDJSON body: the first line, then the argument `chunks`.
|
|
127
|
+
ndjson: Optional[Tuple[Values, object]]
|
|
128
|
+
token: TokenNeeds
|
|
129
|
+
request_headers: Mapping[str, str]
|
|
130
|
+
timeout: float
|
|
131
|
+
max_retries: int
|
|
132
|
+
repeatable: bool
|
|
133
|
+
failure: BodyFailure = field(default_factory=BodyFailure)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Outcome:
|
|
138
|
+
"""The end of one attempt: the response and its data, or an error."""
|
|
139
|
+
|
|
140
|
+
response: Optional[httpx.Response] = None
|
|
141
|
+
data: Any = None
|
|
142
|
+
error: Optional[PierreError] = None
|
|
143
|
+
transient: bool = False
|
|
144
|
+
retry_after: Optional[str] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class BaseRuntime:
|
|
148
|
+
"""The parts of a request that do no I/O."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, config: HttpConfig) -> None:
|
|
151
|
+
self.config = config
|
|
152
|
+
|
|
153
|
+
def prepare(
|
|
154
|
+
self, operation: OperationSpec[Any], params: Mapping[str, Any], options: RequestOptions
|
|
155
|
+
) -> Prepared:
|
|
156
|
+
config = self.config
|
|
157
|
+
ttl = check_ttl(_given(options.token_ttl, REQUEST_TOKEN_SECONDS), "token_ttl")
|
|
158
|
+
# A request option replaces the client option.
|
|
159
|
+
rules = check_ref_policies(_given(options.ref_policies, None), "ref_policies")
|
|
160
|
+
if rules is None:
|
|
161
|
+
rules = config.rules or ()
|
|
162
|
+
if len(rules) > 0 and config.credentials.fixed:
|
|
163
|
+
raise PierreError(
|
|
164
|
+
"A client with a fixed `token` cannot use `ref_policies`. "
|
|
165
|
+
"Give `private_key` or `get_token`."
|
|
166
|
+
)
|
|
167
|
+
timeout = check_timeout(_given(options.timeout, config.timeout))
|
|
168
|
+
max_retries = check_max_retries(_given(options.max_retries, config.max_retries))
|
|
169
|
+
idempotency_key = _idempotency_key(operation, options.idempotency_key)
|
|
170
|
+
|
|
171
|
+
values: Values = {
|
|
172
|
+
name: value for name, value in params.items() if not isinstance(value, NotGiven)
|
|
173
|
+
}
|
|
174
|
+
body = operation.body
|
|
175
|
+
# The SDK names a new repository when the caller does not.
|
|
176
|
+
if (
|
|
177
|
+
operation.repo_claim
|
|
178
|
+
and "repo_name" not in values
|
|
179
|
+
and body is not None
|
|
180
|
+
and "repo_name" in body.fields
|
|
181
|
+
):
|
|
182
|
+
values["repo_name"] = str(uuid.uuid4())
|
|
183
|
+
|
|
184
|
+
headers: List[Tuple[str, str]] = [
|
|
185
|
+
("Pierre-Client", config.client),
|
|
186
|
+
("Accept", "application/json" if operation.response_encoding == "json" else "*/*"),
|
|
187
|
+
]
|
|
188
|
+
# The default headers come after `Pierre-Client`, so a client such as the CLI can name
|
|
189
|
+
# itself. The token comes later, and the request headers come last.
|
|
190
|
+
for name, text in config.default_headers.items():
|
|
191
|
+
_set(headers, name, text)
|
|
192
|
+
query: List[Tuple[str, Union[str, int, float, bool, None]]] = []
|
|
193
|
+
for parameter in operation.parameters:
|
|
194
|
+
if parameter.location == "path" or parameter.field not in values:
|
|
195
|
+
continue
|
|
196
|
+
# `None` cannot be a query or header value, so it raises `PierreError`.
|
|
197
|
+
value = values[parameter.field]
|
|
198
|
+
if parameter.location == "query":
|
|
199
|
+
# A query array is any sequence that is not text: one pair for each value.
|
|
200
|
+
items = (
|
|
201
|
+
value
|
|
202
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
|
203
|
+
else [value]
|
|
204
|
+
)
|
|
205
|
+
query.extend(
|
|
206
|
+
(parameter.name, _wire_text(operation, parameter.field, item)) for item in items
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
_set(headers, parameter.name, _wire_text(operation, parameter.field, value))
|
|
210
|
+
repo: Optional[str] = None
|
|
211
|
+
if operation.repo_claim:
|
|
212
|
+
claim = values.get("repo_name")
|
|
213
|
+
if not isinstance(claim, str):
|
|
214
|
+
raise PierreError(f"{operation.id} needs `repo_name`")
|
|
215
|
+
repo = claim
|
|
216
|
+
if idempotency_key is not None:
|
|
217
|
+
_set(headers, "Idempotency-Key", idempotency_key)
|
|
218
|
+
|
|
219
|
+
content: Optional[bytes] = None
|
|
220
|
+
ndjson: Optional[Tuple[Values, object]] = None
|
|
221
|
+
fields = {} if body is None else _pick(values, body.fields)
|
|
222
|
+
if isinstance(body, JsonBodySpec) and (body.required or len(fields) > 0):
|
|
223
|
+
_set(headers, "Content-Type", "application/json")
|
|
224
|
+
content = _json_text(operation, fields).encode("utf-8")
|
|
225
|
+
elif isinstance(body, NdjsonBodySpec):
|
|
226
|
+
_set(headers, "Content-Type", "application/x-ndjson")
|
|
227
|
+
ndjson = ({body.first: fields}, values.get("chunks"))
|
|
228
|
+
url = httpx.URL(config.base_url.rstrip("/") + _expand_path(operation, values))
|
|
229
|
+
if query:
|
|
230
|
+
url = url.copy_merge_params(query)
|
|
231
|
+
extra = options.extra_headers
|
|
232
|
+
return Prepared(
|
|
233
|
+
operation=operation,
|
|
234
|
+
url=url,
|
|
235
|
+
headers=headers,
|
|
236
|
+
json=content,
|
|
237
|
+
ndjson=ndjson,
|
|
238
|
+
token=TokenNeeds(scopes=operation.scopes, repo=repo, ttl=ttl, rules=rules),
|
|
239
|
+
request_headers=dict(extra) if isinstance(extra, Mapping) else {},
|
|
240
|
+
timeout=timeout,
|
|
241
|
+
max_retries=max_retries,
|
|
242
|
+
# The SDK cannot send a stream twice, so a streaming body never retries.
|
|
243
|
+
repeatable=ndjson is None
|
|
244
|
+
and (operation.method in _SAFE_METHODS or idempotency_key is not None),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def headers_of(self, prepared: Prepared, token: str) -> httpx.Headers:
|
|
248
|
+
headers = list(prepared.headers)
|
|
249
|
+
_set(headers, "Authorization", f"Bearer {token}")
|
|
250
|
+
for name, value in prepared.request_headers.items():
|
|
251
|
+
_set(headers, name, value)
|
|
252
|
+
return httpx.Headers(headers)
|
|
253
|
+
|
|
254
|
+
def success(self, operation: OperationSpec[Any], response: httpx.Response) -> bool:
|
|
255
|
+
return response.is_success or (response.status_code == 304 and operation.not_modified)
|
|
256
|
+
|
|
257
|
+
def failure(self, response: httpx.Response, content: bytes) -> Outcome:
|
|
258
|
+
body = _parse_error_body(content)
|
|
259
|
+
return Outcome(
|
|
260
|
+
response=response,
|
|
261
|
+
error=api_error(response.status_code, body, response.headers),
|
|
262
|
+
transient=_retry.transient_status(response.status_code, problem_code(body)),
|
|
263
|
+
retry_after=response.headers.get("retry-after"),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def connection_error(
|
|
267
|
+
self, prepared: Prepared, error: Exception
|
|
268
|
+
) -> Union[Outcome, BaseException]:
|
|
269
|
+
"""The outcome of an attempt that got no response, or the error to raise as it is."""
|
|
270
|
+
operation = prepared.operation
|
|
271
|
+
# The caller's content failed, not the connection: give its own error.
|
|
272
|
+
if prepared.failure.error is not None:
|
|
273
|
+
return prepared.failure.error
|
|
274
|
+
if isinstance(error, httpx.TimeoutException):
|
|
275
|
+
timeout = APITimeoutError(f"{operation.id}: no response within {prepared.timeout:g} s")
|
|
276
|
+
timeout.__cause__ = error
|
|
277
|
+
return Outcome(error=timeout, transient=True)
|
|
278
|
+
if isinstance(error, PierreError):
|
|
279
|
+
return error
|
|
280
|
+
# Any other failure of the send means that the request got no response, as in
|
|
281
|
+
# TypeScript: a transport can raise more than `httpx.RequestError`.
|
|
282
|
+
connection = APIConnectionError(f"{operation.id}: {error!r}")
|
|
283
|
+
connection.__cause__ = error
|
|
284
|
+
return Outcome(error=connection, transient=True)
|
|
285
|
+
|
|
286
|
+
def timeout_outcome(self, prepared: Prepared) -> Outcome:
|
|
287
|
+
"""The outcome of an attempt that passed its timeout, which a retry can repeat."""
|
|
288
|
+
return Outcome(
|
|
289
|
+
error=APITimeoutError(
|
|
290
|
+
f"{prepared.operation.id}: no response within {prepared.timeout:g} s"
|
|
291
|
+
),
|
|
292
|
+
transient=True,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def json_data(self, operation: OperationSpec[Any], content: bytes) -> Any:
|
|
296
|
+
try:
|
|
297
|
+
return json.loads(content)
|
|
298
|
+
except ValueError as error:
|
|
299
|
+
raise PierreError(f"{operation.id}: the response body is not JSON") from error
|
|
300
|
+
|
|
301
|
+
def none_data(self, operation: OperationSpec[Any], response: httpx.Response) -> Any:
|
|
302
|
+
return {"status": response.status_code, "headers": declared_headers(operation, response)}
|
|
303
|
+
|
|
304
|
+
def result(self, operation: OperationSpec[Any], outcome: Outcome, raw: bool) -> Any:
|
|
305
|
+
"""The data of the outcome, or a `RawResponse` for `with_raw_response`."""
|
|
306
|
+
if not raw:
|
|
307
|
+
return outcome.data
|
|
308
|
+
response = outcome.response
|
|
309
|
+
assert response is not None
|
|
310
|
+
return RawResponse(
|
|
311
|
+
data=outcome.data,
|
|
312
|
+
response=response,
|
|
313
|
+
headers=declared_headers(operation, response),
|
|
314
|
+
request_id=response.headers.get("x-request-id"),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def token_needs(self, repo: str, options: RequestOptions) -> TokenNeeds:
|
|
318
|
+
if self.config.credentials.fixed:
|
|
319
|
+
raise PierreError(
|
|
320
|
+
"A client with a fixed `token` cannot make a token for another repository. "
|
|
321
|
+
"Give that token in the params."
|
|
322
|
+
)
|
|
323
|
+
ttl = check_ttl(_given(options.token_ttl, REQUEST_TOKEN_SECONDS), "token_ttl")
|
|
324
|
+
return TokenNeeds(scopes=("git:read",), repo=repo, ttl=ttl, rules=())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def declared_headers(operation: OperationSpec[Any], response: httpx.Response) -> Dict[str, str]:
|
|
328
|
+
"""The declared response headers that the response has, by field."""
|
|
329
|
+
headers: Dict[str, str] = {}
|
|
330
|
+
for header in operation.response_headers:
|
|
331
|
+
value = response.headers.get(header.name)
|
|
332
|
+
if value is not None:
|
|
333
|
+
headers[header.field] = value
|
|
334
|
+
return headers
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _set(headers: List[Tuple[str, str]], name: str, value: str) -> None:
|
|
338
|
+
"""Sets a header, and removes each other value of its name, without case."""
|
|
339
|
+
lower = name.lower()
|
|
340
|
+
headers[:] = [(key, item) for key, item in headers if key.lower() != lower]
|
|
341
|
+
headers.append((name, value))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _idempotency_key(operation: OperationSpec[Any], key: Union[str, NotGiven]) -> Optional[str]:
|
|
345
|
+
"""The caller's key, or a new UUID for an operation that accepts one."""
|
|
346
|
+
if isinstance(key, NotGiven):
|
|
347
|
+
return str(uuid.uuid4()) if operation.idempotency_key else None
|
|
348
|
+
# The server ignores a key that the operation does not accept, so a retry could repeat the
|
|
349
|
+
# write.
|
|
350
|
+
if not operation.idempotency_key:
|
|
351
|
+
raise PierreError(f"{operation.id} does not accept an idempotency key.")
|
|
352
|
+
size = len(key.encode("utf-8")) if isinstance(key, str) else 0
|
|
353
|
+
if size == 0 or size > _MAX_IDEMPOTENCY_KEY_BYTES:
|
|
354
|
+
raise PierreError(
|
|
355
|
+
f"`idempotency_key` must have 1 to {_MAX_IDEMPOTENCY_KEY_BYTES} bytes of UTF-8."
|
|
356
|
+
)
|
|
357
|
+
return key
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _expand_path(operation: OperationSpec[Any], values: Values) -> str:
|
|
361
|
+
"""Puts the path parameters in the path. Each value is one segment, so `/` is `%2F`."""
|
|
362
|
+
path = operation.path
|
|
363
|
+
for parameter in operation.parameters:
|
|
364
|
+
if parameter.location != "path":
|
|
365
|
+
continue
|
|
366
|
+
value = values.get(parameter.field)
|
|
367
|
+
if value is None or value == "":
|
|
368
|
+
raise PierreError(f"{operation.id} needs `{parameter.field}`")
|
|
369
|
+
segment = quote(_wire_text(operation, parameter.field, value), safe=_SEGMENT_SAFE)
|
|
370
|
+
# A URL parser resolves `.` and `..`, which would send the request to another route.
|
|
371
|
+
if segment in (".", ".."):
|
|
372
|
+
raise PierreError(f"{operation.id}: `{parameter.field}` cannot be `{segment}`")
|
|
373
|
+
path = path.replace(f"{{{parameter.name}}}", segment)
|
|
374
|
+
return path
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _wire_text(operation: OperationSpec[Any], field: str, value: object) -> str:
|
|
378
|
+
"""The text of a path, query, or header value. The model allows only scalar parameters."""
|
|
379
|
+
if isinstance(value, str):
|
|
380
|
+
return value
|
|
381
|
+
if isinstance(value, bool):
|
|
382
|
+
return "true" if value else "false"
|
|
383
|
+
if isinstance(value, int):
|
|
384
|
+
return str(value)
|
|
385
|
+
if isinstance(value, float):
|
|
386
|
+
return str(int(value)) if value.is_integer() else repr(value)
|
|
387
|
+
raise PierreError(f"{operation.id}: `{field}` must be a string, a number, or a boolean")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _pick(values: Values, fields: Tuple[str, ...]) -> Values:
|
|
391
|
+
"""The given fields. A missing field is not sent, and `None` is sent as `null`."""
|
|
392
|
+
return {name: values[name] for name in fields if name in values}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _parse_error_body(content: bytes) -> object:
|
|
396
|
+
text = content.decode("utf-8", errors="replace")
|
|
397
|
+
if text == "":
|
|
398
|
+
return None
|
|
399
|
+
try:
|
|
400
|
+
return json.loads(text)
|
|
401
|
+
except ValueError:
|
|
402
|
+
return text
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _json_text(operation: OperationSpec[Any], value: object) -> str:
|
|
406
|
+
"""The JSON text of a body or of a line. NaN and infinity are not JSON, so they raise
|
|
407
|
+
`PierreError`."""
|
|
408
|
+
try:
|
|
409
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
|
410
|
+
except ValueError as error:
|
|
411
|
+
raise PierreError(f"{operation.id}: the body is not valid JSON: {error}") from error
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _line(operation: OperationSpec[Any], value: object) -> bytes:
|
|
415
|
+
return (_json_text(operation, value) + "\n").encode("utf-8")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class _DeadlineError(Exception):
|
|
419
|
+
"""The deadline of an attempt of `Pierre` passed."""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _check_deadline(deadline: float) -> None:
|
|
423
|
+
if time.monotonic() > deadline:
|
|
424
|
+
raise _DeadlineError()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _ndjson(
|
|
428
|
+
operation: OperationSpec[Any],
|
|
429
|
+
first: Values,
|
|
430
|
+
rest: object,
|
|
431
|
+
failure: BodyFailure,
|
|
432
|
+
deadline: float,
|
|
433
|
+
) -> Iterator[bytes]:
|
|
434
|
+
"""The NDJSON lines of `Pierre`. It records an error of the caller's lines in `failure`, and
|
|
435
|
+
stops the upload when the deadline of the attempt passes."""
|
|
436
|
+
yield _line(operation, first)
|
|
437
|
+
if rest is None:
|
|
438
|
+
return
|
|
439
|
+
if hasattr(rest, "__aiter__") or not isinstance(rest, Iterable):
|
|
440
|
+
error = PierreError("`chunks` must be an iterable.")
|
|
441
|
+
failure.error = error
|
|
442
|
+
raise error
|
|
443
|
+
lines = iter(rest)
|
|
444
|
+
while True:
|
|
445
|
+
try:
|
|
446
|
+
line = next(lines, _END)
|
|
447
|
+
except BaseException as error:
|
|
448
|
+
if failure.error is None:
|
|
449
|
+
failure.error = error
|
|
450
|
+
raise
|
|
451
|
+
if line is _END:
|
|
452
|
+
return
|
|
453
|
+
_check_deadline(deadline)
|
|
454
|
+
yield _line(operation, line)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
_END = object()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class _DeadlineStream(httpx.SyncByteStream):
|
|
461
|
+
"""A response stream that raises `_DeadlineError` when the deadline passes."""
|
|
462
|
+
|
|
463
|
+
def __init__(self, stream: httpx.SyncByteStream, deadline: float) -> None:
|
|
464
|
+
self._stream = stream
|
|
465
|
+
self._deadline = deadline
|
|
466
|
+
|
|
467
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
468
|
+
for chunk in self._stream:
|
|
469
|
+
_check_deadline(self._deadline)
|
|
470
|
+
yield chunk
|
|
471
|
+
|
|
472
|
+
def close(self) -> None:
|
|
473
|
+
self._stream.close()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _read_within(response: httpx.Response, deadline: float) -> bytes:
|
|
477
|
+
"""Reads the body of a response, and raises `_DeadlineError` when the deadline passes.
|
|
478
|
+
`response.read()` keeps the body on the response, so the caller can read it again."""
|
|
479
|
+
if isinstance(response.stream, httpx.SyncByteStream):
|
|
480
|
+
response.stream = _DeadlineStream(response.stream, deadline)
|
|
481
|
+
return response.read()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def _async_ndjson(
|
|
485
|
+
operation: OperationSpec[Any], first: Values, rest: object, failure: BodyFailure
|
|
486
|
+
) -> AsyncIterator[bytes]:
|
|
487
|
+
"""The NDJSON lines of `AsyncPierre`, which also reads an async iterable."""
|
|
488
|
+
yield _line(operation, first)
|
|
489
|
+
if rest is None:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
if isinstance(rest, AsyncIterable):
|
|
493
|
+
async for line in rest:
|
|
494
|
+
yield _line(operation, line)
|
|
495
|
+
elif isinstance(rest, Iterable):
|
|
496
|
+
for line in rest:
|
|
497
|
+
yield _line(operation, line)
|
|
498
|
+
else:
|
|
499
|
+
raise PierreError("`chunks` must be an iterable or an async iterable.")
|
|
500
|
+
except BaseException as error:
|
|
501
|
+
if failure.error is None:
|
|
502
|
+
failure.error = error
|
|
503
|
+
raise
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class SyncRuntime(BaseRuntime):
|
|
507
|
+
"""The transport of `Pierre`. `timeout` covers each attempt: the token, the upload, the
|
|
508
|
+
response headers, and the body of a JSON response. The SDK cannot stop a blocking
|
|
509
|
+
`get_token`, so an attempt fails when its token arrives after the deadline."""
|
|
510
|
+
|
|
511
|
+
def __init__(self, config: HttpConfig, http_client: httpx.Client) -> None:
|
|
512
|
+
super().__init__(config)
|
|
513
|
+
self.http_client = http_client
|
|
514
|
+
|
|
515
|
+
def request(
|
|
516
|
+
self, operation: OperationSpec[Any], params: Mapping[str, Any], options: RequestOptions
|
|
517
|
+
) -> Any:
|
|
518
|
+
"""Sends one request, and returns the result of its encoding."""
|
|
519
|
+
raw = claim_raw()
|
|
520
|
+
outcome = self._send(self.prepare(operation, params, copy_options(options)))
|
|
521
|
+
return self.result(operation, outcome, raw)
|
|
522
|
+
|
|
523
|
+
def list(
|
|
524
|
+
self, operation: OperationSpec[Any], params: Mapping[str, Any], options: RequestOptions
|
|
525
|
+
) -> Page[Any, Any]:
|
|
526
|
+
"""Sends a list request, and returns the first page, which also iterates over every item."""
|
|
527
|
+
raw = claim_raw()
|
|
528
|
+
item_field = operation.item_field
|
|
529
|
+
if item_field is None:
|
|
530
|
+
raise PierreError(f"{operation.id} is not a list")
|
|
531
|
+
# Each next page sends the arguments and the request options of this call.
|
|
532
|
+
first = dict(params)
|
|
533
|
+
first_options = copy_options(options)
|
|
534
|
+
|
|
535
|
+
def page(cursor: Optional[str]) -> Outcome:
|
|
536
|
+
values = first if cursor is None else {**first, "cursor": cursor}
|
|
537
|
+
outcome = self._send(self.prepare(operation, values, first_options))
|
|
538
|
+
fields = _page_fields(outcome.data, item_field)
|
|
539
|
+
assert outcome.response is not None
|
|
540
|
+
outcome.data = Page(
|
|
541
|
+
**fields,
|
|
542
|
+
response=outcome.response,
|
|
543
|
+
fetch=lambda next_cursor: page(next_cursor).data,
|
|
544
|
+
)
|
|
545
|
+
return outcome
|
|
546
|
+
|
|
547
|
+
return self.result(operation, page(None), raw) # type: ignore[no-any-return]
|
|
548
|
+
|
|
549
|
+
def read_token(self, repo: str, options: RequestOptions) -> str:
|
|
550
|
+
"""A token with `git:read` on another repository, for example the source of a copy. The
|
|
551
|
+
timeout covers it, as it covers the token of an attempt."""
|
|
552
|
+
needs = self.token_needs(repo, options)
|
|
553
|
+
timeout = check_timeout(_given(options.timeout, self.config.timeout))
|
|
554
|
+
start = time.monotonic()
|
|
555
|
+
token = self.config.credentials.token(needs)
|
|
556
|
+
if time.monotonic() - start > timeout:
|
|
557
|
+
raise APITimeoutError(f"no token for `{repo}` within {timeout:g} s")
|
|
558
|
+
return token
|
|
559
|
+
|
|
560
|
+
def _send(self, prepared: Prepared) -> Outcome:
|
|
561
|
+
"""Sends the request with its retries, and returns the outcome of the last attempt."""
|
|
562
|
+
for attempt in count():
|
|
563
|
+
outcome = self._attempt(prepared)
|
|
564
|
+
if outcome.error is None:
|
|
565
|
+
return outcome
|
|
566
|
+
if not (prepared.repeatable and outcome.transient and attempt < prepared.max_retries):
|
|
567
|
+
raise outcome.error
|
|
568
|
+
_retry.sleep(_retry.retry_delay(outcome.retry_after, attempt))
|
|
569
|
+
raise AssertionError("unreachable")
|
|
570
|
+
|
|
571
|
+
def _attempt(self, prepared: Prepared) -> Outcome:
|
|
572
|
+
operation = prepared.operation
|
|
573
|
+
deadline = time.monotonic() + prepared.timeout
|
|
574
|
+
token = self.config.credentials.token(prepared.token)
|
|
575
|
+
# httpx gets the time that remains after the token, for each network step.
|
|
576
|
+
remaining = deadline - time.monotonic()
|
|
577
|
+
if remaining <= 0:
|
|
578
|
+
return self.timeout_outcome(prepared)
|
|
579
|
+
content: Union[bytes, Iterator[bytes], None] = prepared.json
|
|
580
|
+
if prepared.ndjson is not None:
|
|
581
|
+
content = _ndjson(operation, *prepared.ndjson, prepared.failure, deadline)
|
|
582
|
+
request = self.http_client.build_request(
|
|
583
|
+
operation.method,
|
|
584
|
+
prepared.url,
|
|
585
|
+
headers=self.headers_of(prepared, token),
|
|
586
|
+
content=content,
|
|
587
|
+
timeout=httpx.Timeout(remaining),
|
|
588
|
+
)
|
|
589
|
+
try:
|
|
590
|
+
response = self.http_client.send(request, stream=True)
|
|
591
|
+
except _DeadlineError:
|
|
592
|
+
return self.timeout_outcome(prepared)
|
|
593
|
+
except Exception as error:
|
|
594
|
+
result = self.connection_error(prepared, error)
|
|
595
|
+
if result is error:
|
|
596
|
+
raise
|
|
597
|
+
if isinstance(result, BaseException):
|
|
598
|
+
raise result from error
|
|
599
|
+
return result
|
|
600
|
+
keep = False
|
|
601
|
+
try:
|
|
602
|
+
if time.monotonic() > deadline:
|
|
603
|
+
return self.timeout_outcome(prepared)
|
|
604
|
+
if not self.success(operation, response):
|
|
605
|
+
try:
|
|
606
|
+
error_body = _read_within(response, deadline)
|
|
607
|
+
except _DeadlineError:
|
|
608
|
+
return self.timeout_outcome(prepared)
|
|
609
|
+
except Exception:
|
|
610
|
+
error_body = b""
|
|
611
|
+
return self.failure(response, error_body)
|
|
612
|
+
if operation.response_encoding == "bytes":
|
|
613
|
+
# The platform stream type: the caller reads the body, and the
|
|
614
|
+
# timeout ends with the headers.
|
|
615
|
+
keep = True
|
|
616
|
+
return Outcome(response=response, data=response)
|
|
617
|
+
# The timeout covers a JSON body that the SDK reads, so a failed read retries.
|
|
618
|
+
try:
|
|
619
|
+
content_bytes = _read_within(response, deadline)
|
|
620
|
+
except _DeadlineError:
|
|
621
|
+
return self.timeout_outcome(prepared)
|
|
622
|
+
except Exception as error:
|
|
623
|
+
result = self.connection_error(prepared, error)
|
|
624
|
+
if isinstance(result, BaseException):
|
|
625
|
+
raise
|
|
626
|
+
return result
|
|
627
|
+
if operation.response_encoding == "json":
|
|
628
|
+
return Outcome(response=response, data=self.json_data(operation, content_bytes))
|
|
629
|
+
if operation.response_encoding == "none":
|
|
630
|
+
return Outcome(response=response, data=self.none_data(operation, response))
|
|
631
|
+
raise PierreError(f"{operation.id}: NDJSON responses are not supported yet")
|
|
632
|
+
finally:
|
|
633
|
+
if not keep:
|
|
634
|
+
response.close()
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class AsyncRuntime(BaseRuntime):
|
|
638
|
+
"""The transport of `AsyncPierre`. `timeout` covers each attempt: the token, the upload, the
|
|
639
|
+
response headers, and the body of a JSON response."""
|
|
640
|
+
|
|
641
|
+
def __init__(self, config: HttpConfig, http_client: httpx.AsyncClient) -> None:
|
|
642
|
+
super().__init__(config)
|
|
643
|
+
self.http_client = http_client
|
|
644
|
+
|
|
645
|
+
async def request(
|
|
646
|
+
self, operation: OperationSpec[Any], params: Mapping[str, Any], options: RequestOptions
|
|
647
|
+
) -> Any:
|
|
648
|
+
raw = claim_raw()
|
|
649
|
+
outcome = await self._send(self.prepare(operation, params, copy_options(options)))
|
|
650
|
+
return self.result(operation, outcome, raw)
|
|
651
|
+
|
|
652
|
+
def list(
|
|
653
|
+
self, operation: OperationSpec[Any], params: Mapping[str, Any], options: RequestOptions
|
|
654
|
+
) -> AsyncPaginator[Any, Any]:
|
|
655
|
+
item_field = operation.item_field
|
|
656
|
+
if item_field is None:
|
|
657
|
+
raise PierreError(f"{operation.id} is not a list")
|
|
658
|
+
first = dict(params)
|
|
659
|
+
first_options = copy_options(options)
|
|
660
|
+
|
|
661
|
+
async def page(cursor: Optional[str]) -> Outcome:
|
|
662
|
+
values = first if cursor is None else {**first, "cursor": cursor}
|
|
663
|
+
outcome = await self._send(self.prepare(operation, values, first_options))
|
|
664
|
+
fields = _page_fields(outcome.data, item_field)
|
|
665
|
+
assert outcome.response is not None
|
|
666
|
+
|
|
667
|
+
async def fetch(next_cursor: str) -> AsyncPage[Any, Any]:
|
|
668
|
+
return (await page(next_cursor)).data # type: ignore[no-any-return]
|
|
669
|
+
|
|
670
|
+
outcome.data = AsyncPage(**fields, response=outcome.response, fetch=fetch)
|
|
671
|
+
return outcome
|
|
672
|
+
|
|
673
|
+
async def start() -> AsyncPage[Any, Any]:
|
|
674
|
+
raw = claim_raw()
|
|
675
|
+
return self.result(operation, await page(None), raw) # type: ignore[no-any-return]
|
|
676
|
+
|
|
677
|
+
return AsyncPaginator(start)
|
|
678
|
+
|
|
679
|
+
async def read_token(self, repo: str, options: RequestOptions) -> str:
|
|
680
|
+
needs = self.token_needs(repo, options)
|
|
681
|
+
timeout = check_timeout(_given(options.timeout, self.config.timeout))
|
|
682
|
+
# The timeout covers this token, as it covers the token of an attempt.
|
|
683
|
+
try:
|
|
684
|
+
return await asyncio.wait_for(self.config.credentials.async_token(needs), timeout)
|
|
685
|
+
except asyncio.TimeoutError:
|
|
686
|
+
raise APITimeoutError(f"no token for `{repo}` within {timeout:g} s") from None
|
|
687
|
+
|
|
688
|
+
async def _send(self, prepared: Prepared) -> Outcome:
|
|
689
|
+
for attempt in count():
|
|
690
|
+
try:
|
|
691
|
+
outcome = await asyncio.wait_for(self._attempt(prepared), prepared.timeout)
|
|
692
|
+
except asyncio.TimeoutError:
|
|
693
|
+
outcome = self.timeout_outcome(prepared)
|
|
694
|
+
if outcome.error is None:
|
|
695
|
+
return outcome
|
|
696
|
+
if not (prepared.repeatable and outcome.transient and attempt < prepared.max_retries):
|
|
697
|
+
raise outcome.error
|
|
698
|
+
await _retry.async_sleep(_retry.retry_delay(outcome.retry_after, attempt))
|
|
699
|
+
raise AssertionError("unreachable")
|
|
700
|
+
|
|
701
|
+
async def _attempt(self, prepared: Prepared) -> Outcome:
|
|
702
|
+
operation = prepared.operation
|
|
703
|
+
token = await self.config.credentials.async_token(prepared.token)
|
|
704
|
+
content: Union[bytes, AsyncIterator[bytes], None] = prepared.json
|
|
705
|
+
if prepared.ndjson is not None:
|
|
706
|
+
content = _async_ndjson(operation, *prepared.ndjson, prepared.failure)
|
|
707
|
+
request = self.http_client.build_request(
|
|
708
|
+
operation.method,
|
|
709
|
+
prepared.url,
|
|
710
|
+
headers=self.headers_of(prepared, token),
|
|
711
|
+
content=content,
|
|
712
|
+
timeout=httpx.Timeout(prepared.timeout),
|
|
713
|
+
)
|
|
714
|
+
try:
|
|
715
|
+
response = await self.http_client.send(request, stream=True)
|
|
716
|
+
except Exception as error:
|
|
717
|
+
result = self.connection_error(prepared, error)
|
|
718
|
+
if result is error:
|
|
719
|
+
raise
|
|
720
|
+
if isinstance(result, BaseException):
|
|
721
|
+
raise result from error
|
|
722
|
+
return result
|
|
723
|
+
keep = False
|
|
724
|
+
try:
|
|
725
|
+
if not self.success(operation, response):
|
|
726
|
+
return self.failure(response, await _async_read_quietly(response))
|
|
727
|
+
if operation.response_encoding == "bytes":
|
|
728
|
+
keep = True
|
|
729
|
+
return Outcome(response=response, data=response)
|
|
730
|
+
try:
|
|
731
|
+
content_bytes = await response.aread()
|
|
732
|
+
except Exception as error:
|
|
733
|
+
result = self.connection_error(prepared, error)
|
|
734
|
+
if isinstance(result, BaseException):
|
|
735
|
+
raise
|
|
736
|
+
return result
|
|
737
|
+
if operation.response_encoding == "json":
|
|
738
|
+
return Outcome(response=response, data=self.json_data(operation, content_bytes))
|
|
739
|
+
if operation.response_encoding == "none":
|
|
740
|
+
return Outcome(response=response, data=self.none_data(operation, response))
|
|
741
|
+
raise PierreError(f"{operation.id}: NDJSON responses are not supported yet")
|
|
742
|
+
finally:
|
|
743
|
+
if not keep:
|
|
744
|
+
await response.aclose()
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _page_fields(value: object, item_field: str) -> Dict[str, Any]:
|
|
748
|
+
body: Mapping[str, Any] = value if isinstance(value, dict) else {}
|
|
749
|
+
items = body.get(item_field)
|
|
750
|
+
next_cursor = body.get("next_cursor")
|
|
751
|
+
return {
|
|
752
|
+
"data": items if isinstance(items, list) else [],
|
|
753
|
+
"has_more": body.get("has_more") is True,
|
|
754
|
+
"next_cursor": next_cursor if isinstance(next_cursor, str) else None,
|
|
755
|
+
"body": value,
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
async def _async_read_quietly(response: httpx.Response) -> bytes:
|
|
760
|
+
try:
|
|
761
|
+
return await response.aread()
|
|
762
|
+
except Exception:
|
|
763
|
+
return b""
|