sci-protocol 0.2.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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""HTTP with injectable transports, operation deadlines, bounded retries, and pacing.
|
|
2
|
+
|
|
3
|
+
The default transport uses :mod:`urllib.request`. Tests and embedding
|
|
4
|
+
programs pass any object implementing :class:`Transport`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import email.utils
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import secrets
|
|
16
|
+
import socket
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections.abc import Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, BinaryIO, Protocol
|
|
26
|
+
|
|
27
|
+
from pysci.errors import SciError
|
|
28
|
+
from pysci.jsonstrict import JsonValue, parse_json
|
|
29
|
+
|
|
30
|
+
from ..errors import CliError, Exit
|
|
31
|
+
|
|
32
|
+
READ_CHUNK = 1 << 16
|
|
33
|
+
DEFAULT_TIMEOUT = 120.0
|
|
34
|
+
RETRY_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
35
|
+
MAX_ATTEMPTS = 6
|
|
36
|
+
BACKOFF_BASE = 0.5
|
|
37
|
+
BACKOFF_CAP = 20.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Stream(Protocol):
|
|
41
|
+
def read(self, size: int = -1, /) -> bytes: ...
|
|
42
|
+
|
|
43
|
+
def close(self) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class HttpRequest:
|
|
48
|
+
method: str
|
|
49
|
+
url: str
|
|
50
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
body: bytes | Path | None = None
|
|
52
|
+
unredirected: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
"""Headers (e.g. credentials) that must not follow redirects to other hosts."""
|
|
54
|
+
|
|
55
|
+
def body_bytes(self) -> bytes:
|
|
56
|
+
if self.body is None:
|
|
57
|
+
return b""
|
|
58
|
+
if isinstance(self.body, Path):
|
|
59
|
+
return self.body.read_bytes()
|
|
60
|
+
return self.body
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class RawResponse:
|
|
65
|
+
status: int
|
|
66
|
+
headers: dict[str, str]
|
|
67
|
+
"""Lower-cased header names."""
|
|
68
|
+
stream: Stream
|
|
69
|
+
url: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TransportError(Exception):
|
|
73
|
+
"""The request could not be completed (connection, DNS, TLS, or timeout)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str, *, sent: bool = True) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.sent = sent
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Transport(Protocol):
|
|
81
|
+
def send(self, request: HttpRequest, timeout: float) -> RawResponse: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UrllibTransport:
|
|
85
|
+
"""Transport over :mod:`urllib.request`. Follows redirects for GET/HEAD.
|
|
86
|
+
|
|
87
|
+
Credentials go into unredirected headers, so they are not forwarded to a
|
|
88
|
+
redirect target.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self._opener = urllib.request.build_opener()
|
|
93
|
+
|
|
94
|
+
def send(self, request: HttpRequest, timeout: float) -> RawResponse:
|
|
95
|
+
data: Any = None
|
|
96
|
+
headers = dict(request.headers)
|
|
97
|
+
if isinstance(request.body, Path):
|
|
98
|
+
data = open(request.body, "rb") # noqa: SIM115 - closed below
|
|
99
|
+
headers["Content-Length"] = str(request.body.stat().st_size)
|
|
100
|
+
elif request.body is not None:
|
|
101
|
+
data = request.body
|
|
102
|
+
req = urllib.request.Request(request.url, data=data, headers=headers, method=request.method)
|
|
103
|
+
for name, value in request.unredirected.items():
|
|
104
|
+
req.add_unredirected_header(name, value)
|
|
105
|
+
try:
|
|
106
|
+
response = self._opener.open(req, timeout=max(timeout, 0.001))
|
|
107
|
+
return RawResponse(
|
|
108
|
+
response.status,
|
|
109
|
+
{k.lower(): v for k, v in response.headers.items()},
|
|
110
|
+
response,
|
|
111
|
+
response.url,
|
|
112
|
+
)
|
|
113
|
+
except urllib.error.HTTPError as exc:
|
|
114
|
+
return RawResponse(
|
|
115
|
+
exc.code,
|
|
116
|
+
{k.lower(): v for k, v in (exc.headers or {}).items()},
|
|
117
|
+
exc if exc.fp is not None else io.BytesIO(b""),
|
|
118
|
+
request.url,
|
|
119
|
+
)
|
|
120
|
+
except (TimeoutError, urllib.error.URLError, OSError, ValueError) as exc:
|
|
121
|
+
reason = getattr(exc, "reason", exc)
|
|
122
|
+
sent = not isinstance(reason, socket.gaierror | ConnectionRefusedError)
|
|
123
|
+
raise TransportError(f"{type(reason).__name__}: {reason}", sent=sent) from None
|
|
124
|
+
finally:
|
|
125
|
+
if hasattr(data, "close"):
|
|
126
|
+
data.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- deadline
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Deadline:
|
|
133
|
+
"""An operation deadline including retries (``--timeout``)."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic) -> None:
|
|
136
|
+
self.seconds = seconds
|
|
137
|
+
self._clock = clock
|
|
138
|
+
self._end = clock() + seconds
|
|
139
|
+
|
|
140
|
+
def remaining(self) -> float:
|
|
141
|
+
return self._end - self._clock()
|
|
142
|
+
|
|
143
|
+
def expired(self) -> bool:
|
|
144
|
+
return self.remaining() <= 0
|
|
145
|
+
|
|
146
|
+
def extend(self, seconds: float) -> None:
|
|
147
|
+
"""Move the deadline later, for time spent in a wait that is not the operation's."""
|
|
148
|
+
self._end += seconds
|
|
149
|
+
|
|
150
|
+
def check(self, what: str) -> float:
|
|
151
|
+
left = self.remaining()
|
|
152
|
+
if left <= 0:
|
|
153
|
+
raise CliError(
|
|
154
|
+
"deadline_exceeded",
|
|
155
|
+
f"operation deadline of {self.seconds:g}s exceeded during {what}",
|
|
156
|
+
Exit.INCOMPLETE,
|
|
157
|
+
retryable=True,
|
|
158
|
+
)
|
|
159
|
+
return left
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------- pacing
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Pacer:
|
|
166
|
+
"""At most one paced request per ``interval`` seconds across processes.
|
|
167
|
+
|
|
168
|
+
Applies to timestamp authority requests and index writes from this
|
|
169
|
+
machine (FreeTSA usage policy: one request per second). The last request
|
|
170
|
+
time is kept in ``<state>/pace`` under an advisory lock.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
interval: float = 1.0
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
path: Path | None,
|
|
178
|
+
*,
|
|
179
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
180
|
+
wall: Callable[[], float] = time.time,
|
|
181
|
+
) -> None:
|
|
182
|
+
self.path = path
|
|
183
|
+
self._sleep = sleep
|
|
184
|
+
self._wall = wall
|
|
185
|
+
self._last = 0.0
|
|
186
|
+
|
|
187
|
+
def wait(self) -> None:
|
|
188
|
+
if self.path is None:
|
|
189
|
+
self._wait_local()
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
194
|
+
except OSError:
|
|
195
|
+
self._wait_local()
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
_lock(fd)
|
|
199
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
200
|
+
raw = os.read(fd, 64)
|
|
201
|
+
try:
|
|
202
|
+
last = float(raw.decode("ascii").strip() or 0)
|
|
203
|
+
except ValueError:
|
|
204
|
+
last = 0.0
|
|
205
|
+
last = max(last, self._last)
|
|
206
|
+
delay = last + self.interval - self._wall()
|
|
207
|
+
if delay > 0:
|
|
208
|
+
self._sleep(min(delay, self.interval))
|
|
209
|
+
now = f"{self._wall():.6f}".encode("ascii")
|
|
210
|
+
os.ftruncate(fd, 0)
|
|
211
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
212
|
+
os.write(fd, now)
|
|
213
|
+
self._last = self._wall()
|
|
214
|
+
finally:
|
|
215
|
+
os.close(fd)
|
|
216
|
+
|
|
217
|
+
def _wait_local(self) -> None:
|
|
218
|
+
delay = self._last + self.interval - self._wall()
|
|
219
|
+
if delay > 0:
|
|
220
|
+
self._sleep(min(delay, self.interval))
|
|
221
|
+
self._last = self._wall()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _lock(fd: int) -> None:
|
|
225
|
+
try:
|
|
226
|
+
import fcntl
|
|
227
|
+
except ImportError: # pragma: no cover - Windows: in-process pacing only
|
|
228
|
+
return
|
|
229
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --------------------------------------------------------------------------- client
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(slots=True)
|
|
236
|
+
class Response:
|
|
237
|
+
status: int
|
|
238
|
+
headers: dict[str, str]
|
|
239
|
+
body: bytes
|
|
240
|
+
url: str
|
|
241
|
+
attempts: int = 1
|
|
242
|
+
|
|
243
|
+
def header(self, name: str) -> str | None:
|
|
244
|
+
return self.headers.get(name.lower())
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def content_type(self) -> str:
|
|
248
|
+
return (self.header("content-type") or "").split(";", 1)[0].strip().lower()
|
|
249
|
+
|
|
250
|
+
def json(self, what: str) -> JsonValue:
|
|
251
|
+
try:
|
|
252
|
+
return parse_json(self.body, file=what)
|
|
253
|
+
except SciError as exc:
|
|
254
|
+
raise CliError(
|
|
255
|
+
"invalid_response",
|
|
256
|
+
f"{what} is not valid JSON: {exc.issue.requirement}",
|
|
257
|
+
Exit.INVALID,
|
|
258
|
+
url=self.url,
|
|
259
|
+
) from None
|
|
260
|
+
|
|
261
|
+
def json_object(self, what: str) -> dict[str, JsonValue]:
|
|
262
|
+
value = self.json(what)
|
|
263
|
+
if not isinstance(value, dict):
|
|
264
|
+
raise CliError(
|
|
265
|
+
"invalid_response", f"{what} must be a JSON object", Exit.INVALID, url=self.url
|
|
266
|
+
)
|
|
267
|
+
return value
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ResponseTooLarge(Exception):
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class NetworkError(CliError):
|
|
275
|
+
"""A request failed at the transport level after the permitted retries."""
|
|
276
|
+
|
|
277
|
+
def __init__(self, url: str, message: str, *, lost_response: bool) -> None:
|
|
278
|
+
super().__init__(
|
|
279
|
+
"network_error",
|
|
280
|
+
f"request to {_redact(url)} failed: {message}",
|
|
281
|
+
Exit.INCOMPLETE,
|
|
282
|
+
retryable=True,
|
|
283
|
+
url=_redact(url),
|
|
284
|
+
)
|
|
285
|
+
self.lost_response = lost_response
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _redact(url: str) -> str:
|
|
289
|
+
parts = urllib.parse.urlsplit(url)
|
|
290
|
+
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def retry_after_seconds(value: str | None, now: datetime | None = None) -> float | None:
|
|
294
|
+
"""Parse ``Retry-After`` (delta-seconds or HTTP-date)."""
|
|
295
|
+
if not value:
|
|
296
|
+
return None
|
|
297
|
+
value = value.strip()
|
|
298
|
+
if value.isdigit():
|
|
299
|
+
return float(value)
|
|
300
|
+
try:
|
|
301
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
302
|
+
except (TypeError, ValueError):
|
|
303
|
+
return None
|
|
304
|
+
if when.tzinfo is None:
|
|
305
|
+
when = when.replace(tzinfo=UTC)
|
|
306
|
+
return max(0.0, (when - (now or datetime.now(UTC))).total_seconds())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class HttpClient:
|
|
310
|
+
"""Requests with deadlines, retries (backoff, jitter, Retry-After), and limits."""
|
|
311
|
+
|
|
312
|
+
def __init__(
|
|
313
|
+
self,
|
|
314
|
+
transport: Transport,
|
|
315
|
+
*,
|
|
316
|
+
user_agent: str,
|
|
317
|
+
pacer: Pacer | None = None,
|
|
318
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
319
|
+
rng: random.Random | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
self.transport = transport
|
|
322
|
+
self.user_agent = user_agent
|
|
323
|
+
self.pacer = pacer or Pacer(None, sleep=sleep)
|
|
324
|
+
self.sleep = sleep
|
|
325
|
+
self.rng = rng or random.Random(secrets.randbits(64))
|
|
326
|
+
|
|
327
|
+
def request(
|
|
328
|
+
self,
|
|
329
|
+
method: str,
|
|
330
|
+
url: str,
|
|
331
|
+
*,
|
|
332
|
+
deadline: Deadline,
|
|
333
|
+
headers: Mapping[str, str] | None = None,
|
|
334
|
+
body: bytes | Path | None = None,
|
|
335
|
+
auth: str | None = None,
|
|
336
|
+
max_bytes: int = 1 << 20,
|
|
337
|
+
sink: BinaryIO | None = None,
|
|
338
|
+
retry: bool = True,
|
|
339
|
+
paced: bool = False,
|
|
340
|
+
retry_statuses: frozenset[int] = RETRY_STATUSES,
|
|
341
|
+
should_retry: Callable[[Response], bool] | None = None,
|
|
342
|
+
) -> Response:
|
|
343
|
+
"""Send a request; return the final response (any status).
|
|
344
|
+
|
|
345
|
+
Temporary failures (transport errors and ``retry_statuses``) are
|
|
346
|
+
retried with bounded exponential backoff and jitter, honouring
|
|
347
|
+
``Retry-After``, until the attempt limit or the deadline. Policy and
|
|
348
|
+
validation refusals (other statuses) are returned without retry.
|
|
349
|
+
With ``sink`` the body is streamed there instead of into memory.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
NetworkError: the transport failed on the last permitted attempt.
|
|
353
|
+
CliError: the response exceeded ``max_bytes`` (exit 5).
|
|
354
|
+
"""
|
|
355
|
+
attempt = 0
|
|
356
|
+
all_headers = {"User-Agent": self.user_agent, **(headers or {})}
|
|
357
|
+
unredirected = {"Authorization": auth} if auth else {}
|
|
358
|
+
while True:
|
|
359
|
+
attempt += 1
|
|
360
|
+
left = deadline.check(f"{method} {_redact(url)}")
|
|
361
|
+
if paced:
|
|
362
|
+
self.pacer.wait()
|
|
363
|
+
left = deadline.check(f"{method} {_redact(url)}")
|
|
364
|
+
request = HttpRequest(method, url, dict(all_headers), body, dict(unredirected))
|
|
365
|
+
try:
|
|
366
|
+
raw = self.transport.send(request, left)
|
|
367
|
+
except TransportError as exc:
|
|
368
|
+
if not retry or attempt >= MAX_ATTEMPTS:
|
|
369
|
+
raise NetworkError(url, str(exc), lost_response=exc.sent) from None
|
|
370
|
+
if not self._backoff(attempt, None, deadline):
|
|
371
|
+
raise NetworkError(url, str(exc), lost_response=exc.sent) from None
|
|
372
|
+
continue
|
|
373
|
+
try:
|
|
374
|
+
response = self._read(raw, url, max_bytes, sink, attempt)
|
|
375
|
+
except TransportError as exc:
|
|
376
|
+
if (
|
|
377
|
+
not retry
|
|
378
|
+
or attempt >= MAX_ATTEMPTS
|
|
379
|
+
or not self._backoff(attempt, None, deadline)
|
|
380
|
+
):
|
|
381
|
+
raise NetworkError(url, str(exc), lost_response=True) from None
|
|
382
|
+
continue
|
|
383
|
+
temporary = response.status in retry_statuses or (
|
|
384
|
+
should_retry is not None and should_retry(response)
|
|
385
|
+
)
|
|
386
|
+
if not temporary or not retry or attempt >= MAX_ATTEMPTS:
|
|
387
|
+
return response
|
|
388
|
+
wait = retry_after_seconds(response.header("retry-after"))
|
|
389
|
+
if not self._backoff(attempt, wait, deadline):
|
|
390
|
+
return response
|
|
391
|
+
|
|
392
|
+
def _backoff(self, attempt: int, retry_after: float | None, deadline: Deadline) -> bool:
|
|
393
|
+
delay = min(BACKOFF_CAP, BACKOFF_BASE * 2 ** (attempt - 1))
|
|
394
|
+
delay = self.rng.uniform(delay / 2, delay)
|
|
395
|
+
if retry_after is not None:
|
|
396
|
+
delay = retry_after + self.rng.uniform(0, 0.25)
|
|
397
|
+
if delay >= deadline.remaining():
|
|
398
|
+
return False
|
|
399
|
+
self.sleep(delay)
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
def _read(
|
|
403
|
+
self, raw: RawResponse, url: str, max_bytes: int, sink: BinaryIO | None, attempt: int
|
|
404
|
+
) -> Response:
|
|
405
|
+
total = 0
|
|
406
|
+
chunks: list[bytes] = []
|
|
407
|
+
if sink is not None:
|
|
408
|
+
sink.seek(0)
|
|
409
|
+
sink.truncate()
|
|
410
|
+
stream_ok = 200 <= raw.status < 300
|
|
411
|
+
try:
|
|
412
|
+
while True:
|
|
413
|
+
try:
|
|
414
|
+
chunk = raw.stream.read(READ_CHUNK)
|
|
415
|
+
except (TimeoutError, OSError, ValueError) as exc:
|
|
416
|
+
raise TransportError(f"response interrupted: {exc}") from None
|
|
417
|
+
if not chunk:
|
|
418
|
+
break
|
|
419
|
+
total += len(chunk)
|
|
420
|
+
limit = max_bytes if stream_ok else min(max_bytes, 1 << 20)
|
|
421
|
+
if total > limit:
|
|
422
|
+
raise CliError(
|
|
423
|
+
"response_too_large",
|
|
424
|
+
f"response from {_redact(url)} exceeds the local limit of {limit} bytes",
|
|
425
|
+
Exit.INCOMPLETE,
|
|
426
|
+
url=_redact(url),
|
|
427
|
+
)
|
|
428
|
+
if sink is not None and stream_ok:
|
|
429
|
+
sink.write(chunk)
|
|
430
|
+
else:
|
|
431
|
+
chunks.append(chunk)
|
|
432
|
+
finally:
|
|
433
|
+
with contextlib.suppress(Exception):
|
|
434
|
+
raw.stream.close()
|
|
435
|
+
return Response(raw.status, raw.headers, b"".join(chunks), raw.url or url, attempt)
|
|
436
|
+
|
|
437
|
+
# convenience -------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def get(self, url: str, *, deadline: Deadline, **kwargs: Any) -> Response:
|
|
440
|
+
return self.request("GET", url, deadline=deadline, **kwargs)
|
|
441
|
+
|
|
442
|
+
def post_json(
|
|
443
|
+
self, url: str, document: object, *, deadline: Deadline, **kwargs: Any
|
|
444
|
+
) -> Response:
|
|
445
|
+
body = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
446
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
447
|
+
headers.update(kwargs.pop("headers", {}) or {})
|
|
448
|
+
return self.request("POST", url, deadline=deadline, headers=headers, body=body, **kwargs)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def multipart(parts: list[tuple[str, str | None, bytes, str]]) -> tuple[bytes, str]:
|
|
452
|
+
"""Encode ``(name, filename, data, content_type)`` parts as multipart/form-data."""
|
|
453
|
+
boundary = "sci-" + secrets.token_hex(16)
|
|
454
|
+
out = io.BytesIO()
|
|
455
|
+
for name, filename, data, content_type in parts:
|
|
456
|
+
out.write(f"--{boundary}\r\n".encode("ascii"))
|
|
457
|
+
disposition = f'form-data; name="{name}"'
|
|
458
|
+
if filename is not None:
|
|
459
|
+
disposition += f'; filename="{filename}"'
|
|
460
|
+
out.write(f"Content-Disposition: {disposition}\r\n".encode("ascii"))
|
|
461
|
+
out.write(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
|
|
462
|
+
out.write(data)
|
|
463
|
+
out.write(b"\r\n")
|
|
464
|
+
out.write(f"--{boundary}--\r\n".encode("ascii"))
|
|
465
|
+
return out.getvalue(), f"multipart/form-data; boundary={boundary}"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def status_error(response: Response, what: str) -> CliError:
|
|
469
|
+
"""A CliError for an unexpected HTTP status (5xx/429 temporary, others invalid)."""
|
|
470
|
+
temporary = response.status in RETRY_STATUSES
|
|
471
|
+
record: dict[str, Any] = {"status": response.status, "url": _redact(response.url)}
|
|
472
|
+
retry_after = response.header("retry-after")
|
|
473
|
+
if retry_after:
|
|
474
|
+
record["retry_after"] = retry_after
|
|
475
|
+
return CliError(
|
|
476
|
+
"temporary_failure" if temporary else "unexpected_status",
|
|
477
|
+
f"{what} returned HTTP {response.status}",
|
|
478
|
+
Exit.INCOMPLETE if temporary else Exit.INVALID,
|
|
479
|
+
retryable=temporary,
|
|
480
|
+
**record,
|
|
481
|
+
)
|