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/_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""