agentenv-framework-protocol 0.1.269__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.
- agentenv_framework_protocol-0.1.269.dist-info/METADATA +599 -0
- agentenv_framework_protocol-0.1.269.dist-info/RECORD +20 -0
- agentenv_framework_protocol-0.1.269.dist-info/WHEEL +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/LICENSE +202 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/NOTICE +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/THIRD_PARTY_NOTICES.md +1701 -0
- agentenv_protocol/__init__.py +121 -0
- agentenv_protocol/a2a_agent/__init__.py +204 -0
- agentenv_protocol/a2a_agent/_triggers.py +489 -0
- agentenv_protocol/a2a_agent/extensions.py +1151 -0
- agentenv_protocol/a2a_agent/framework.py +1283 -0
- agentenv_protocol/a2a_agent/registry.py +449 -0
- agentenv_protocol/a2a_agent/tasks/__init__.py +39 -0
- agentenv_protocol/a2a_agent/tasks/v1.py +408 -0
- agentenv_protocol/agent_env_environment.py +653 -0
- agentenv_protocol/client.py +185 -0
- agentenv_protocol/manifest.py +203 -0
- agentenv_protocol/preflight.py +81 -0
- agentenv_protocol/transfers.py +554 -0
- agentenv_protocol/types.py +165 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Provider-neutral object-transfer types and exact-object helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
import io
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from functools import partial
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, BinaryIO, Literal, TypeVar
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from pydantic import (
|
|
24
|
+
AfterValidator,
|
|
25
|
+
BaseModel,
|
|
26
|
+
ConfigDict,
|
|
27
|
+
Field,
|
|
28
|
+
StrictInt,
|
|
29
|
+
model_validator,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_MIME_TYPE = re.compile(r"^[!#$&^_.+\-|~0-9A-Za-z]+/[!#$&^_.+\-|~0-9A-Za-z]+$")
|
|
33
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
34
|
+
_CHUNK_BYTES = 64 * 1024
|
|
35
|
+
_UTC = timezone.utc # noqa: UP017 -- datetime.UTC requires Python 3.11.
|
|
36
|
+
# Idle limits, not totals, as long as botocore's: long enough for a slow uplink to drain its
|
|
37
|
+
# buffers before the store answers, short enough that a stalled connection is retried within
|
|
38
|
+
# the minutes agent-env waits for an extension call.
|
|
39
|
+
TRANSFER_IDLE_TIMEOUT_SECONDS = 60.0
|
|
40
|
+
TRANSFER_ATTEMPTS = 3
|
|
41
|
+
_CONNECT_TIMEOUT_SECONDS = 30.0
|
|
42
|
+
_RETRY_BACKOFF_SECONDS = 1.0
|
|
43
|
+
_TRANSFER_TIMEOUT = httpx.Timeout(
|
|
44
|
+
connect=_CONNECT_TIMEOUT_SECONDS,
|
|
45
|
+
read=TRANSFER_IDLE_TIMEOUT_SECONDS,
|
|
46
|
+
write=TRANSFER_IDLE_TIMEOUT_SECONDS,
|
|
47
|
+
pool=_CONNECT_TIMEOUT_SECONDS,
|
|
48
|
+
)
|
|
49
|
+
# The longest the helpers take to give up on a transfer whose every connection stalls.
|
|
50
|
+
TRANSFER_STALL_BUDGET_SECONDS = TRANSFER_ATTEMPTS * (
|
|
51
|
+
_CONNECT_TIMEOUT_SECONDS + TRANSFER_IDLE_TIMEOUT_SECONDS
|
|
52
|
+
) + sum(_RETRY_BACKOFF_SECONDS * 2**attempt for attempt in range(TRANSFER_ATTEMPTS - 1))
|
|
53
|
+
_T = TypeVar("_T")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _https_url(value: str) -> str:
|
|
57
|
+
parsed = urlsplit(value)
|
|
58
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
59
|
+
raise ValueError("must be an absolute HTTPS URL")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _utc_timestamp(value: datetime) -> datetime:
|
|
64
|
+
offset = value.utcoffset()
|
|
65
|
+
if offset is None:
|
|
66
|
+
raise ValueError("must be an RFC 3339 UTC timestamp")
|
|
67
|
+
if offset.total_seconds() != 0:
|
|
68
|
+
raise ValueError("must use UTC")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _media_type(value: str) -> str:
|
|
73
|
+
if not _MIME_TYPE.fullmatch(value):
|
|
74
|
+
raise ValueError("must be a concrete MIME type")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _sha256(value: str) -> str:
|
|
79
|
+
if not _SHA256.fullmatch(value):
|
|
80
|
+
raise ValueError("must be 64 lowercase hexadecimal characters")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _relative_path(value: str) -> str:
|
|
85
|
+
if "\\" in value or any(part in ("", ".", "..") for part in value.split("/")):
|
|
86
|
+
raise ValueError("must be a normalized relative POSIX path")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
HttpsUrl = Annotated[str, AfterValidator(_https_url)]
|
|
91
|
+
UtcTimestamp = Annotated[datetime, AfterValidator(_utc_timestamp)]
|
|
92
|
+
MediaType = Annotated[str, AfterValidator(_media_type)]
|
|
93
|
+
Sha256 = Annotated[str, AfterValidator(_sha256)]
|
|
94
|
+
RelativePath = Annotated[str, AfterValidator(_relative_path)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TransferModel(BaseModel):
|
|
98
|
+
"""Closed, immutable base model for transfer wire types."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class HttpGetGrant(TransferModel):
|
|
104
|
+
kind: Literal["http-get"]
|
|
105
|
+
url: HttpsUrl = Field(repr=False)
|
|
106
|
+
expires_at: UtcTimestamp
|
|
107
|
+
headers: dict[str, str] | None = Field(default=None, repr=False)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HttpPutGrant(TransferModel):
|
|
111
|
+
kind: Literal["http-put"]
|
|
112
|
+
url: HttpsUrl = Field(repr=False)
|
|
113
|
+
expires_at: UtcTimestamp
|
|
114
|
+
headers: dict[str, str] | None = Field(default=None, repr=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class HttpPostPolicyGrant(TransferModel):
|
|
118
|
+
kind: Literal["http-post-policy"]
|
|
119
|
+
url: HttpsUrl = Field(repr=False)
|
|
120
|
+
fields: dict[str, str] = Field(repr=False)
|
|
121
|
+
path_field: str = Field(min_length=1)
|
|
122
|
+
file_field: str = Field(min_length=1)
|
|
123
|
+
headers: dict[str, str] | None = Field(default=None, repr=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WriteNamespaceGrant(TransferModel):
|
|
127
|
+
root_path: RelativePath
|
|
128
|
+
expires_at: UtcTimestamp
|
|
129
|
+
max_objects: StrictInt = Field(gt=0)
|
|
130
|
+
max_object_bytes: StrictInt = Field(gt=0)
|
|
131
|
+
max_total_bytes: StrictInt = Field(gt=0)
|
|
132
|
+
write: HttpPostPolicyGrant
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ReadObject(TransferModel):
|
|
136
|
+
media_type: MediaType
|
|
137
|
+
max_bytes: StrictInt = Field(gt=0)
|
|
138
|
+
size_bytes: StrictInt | None = Field(default=None, ge=0)
|
|
139
|
+
sha256: Sha256 | None = None
|
|
140
|
+
read: HttpGetGrant
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def _known_size_is_bounded(self) -> ReadObject:
|
|
144
|
+
if self.size_bytes is not None and self.size_bytes > self.max_bytes:
|
|
145
|
+
raise ValueError("size_bytes must not exceed max_bytes")
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class WriteObject(TransferModel):
|
|
150
|
+
media_type: MediaType
|
|
151
|
+
max_bytes: StrictInt = Field(gt=0)
|
|
152
|
+
write: HttpPutGrant
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Uploaded(TransferModel):
|
|
156
|
+
size_bytes: StrictInt = Field(ge=0)
|
|
157
|
+
sha256: Sha256 | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_ErrorCode = Literal[
|
|
161
|
+
"invalid_transfer",
|
|
162
|
+
"grant_expired",
|
|
163
|
+
"transfer_too_large",
|
|
164
|
+
"integrity_mismatch",
|
|
165
|
+
"transfer_rejected",
|
|
166
|
+
"transfer_unavailable",
|
|
167
|
+
"transfer_timeout",
|
|
168
|
+
]
|
|
169
|
+
_ERROR_STATUS: dict[str, tuple[int, bool]] = {
|
|
170
|
+
"invalid_transfer": (400, False),
|
|
171
|
+
"grant_expired": (410, False),
|
|
172
|
+
"transfer_too_large": (413, False),
|
|
173
|
+
"integrity_mismatch": (422, False),
|
|
174
|
+
"transfer_rejected": (502, False),
|
|
175
|
+
"transfer_unavailable": (502, True),
|
|
176
|
+
"transfer_timeout": (504, True),
|
|
177
|
+
}
|
|
178
|
+
_TOO_LARGE = "The transfer exceeds its configured size limit."
|
|
179
|
+
_UNREADABLE = "The transfer source could not be read."
|
|
180
|
+
_UNAVAILABLE = "The object store is temporarily unavailable."
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TransferError(RuntimeError):
|
|
184
|
+
"""Sanitized object-transfer failure suitable for an extension error body."""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self, code: _ErrorCode, message: str, *, retryable: bool | None = None
|
|
188
|
+
) -> None:
|
|
189
|
+
super().__init__(message)
|
|
190
|
+
self.code = code
|
|
191
|
+
self.status_code, default_retryable = _ERROR_STATUS[code]
|
|
192
|
+
self.retryable = default_retryable if retryable is None else retryable
|
|
193
|
+
|
|
194
|
+
def body(self) -> dict[str, object]:
|
|
195
|
+
return {
|
|
196
|
+
"error": {
|
|
197
|
+
"code": self.code,
|
|
198
|
+
"message": str(self),
|
|
199
|
+
"retryable": self.retryable,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_HTTPX_LOGGER = logging.getLogger("httpx")
|
|
205
|
+
_REDACT_REQUEST_URLS: ContextVar[bool] = ContextVar(
|
|
206
|
+
"agentenv_protocol_redact_request_urls", default=False
|
|
207
|
+
)
|
|
208
|
+
_URL = re.compile(
|
|
209
|
+
r"(?P<scheme>https?)://(?:[^/?#@\s\"']*@)?(?P<host>[^/?#\s\"']+)[^\s\"']*"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _RequestUrlRedactor(logging.Filter):
|
|
214
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
215
|
+
# The formatted message, not httpx's arguments: it holds however httpx logs.
|
|
216
|
+
if _REDACT_REQUEST_URLS.get():
|
|
217
|
+
record.msg = _URL.sub(
|
|
218
|
+
r"\g<scheme>://\g<host>/<redacted>", record.getMessage()
|
|
219
|
+
)
|
|
220
|
+
record.args = None
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_HTTPX_LOGGER.addFilter(_RequestUrlRedactor())
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@contextmanager
|
|
228
|
+
def _redacting_request_urls() -> Iterator[None]:
|
|
229
|
+
"""httpx logs only the origin of requests made in this scope: grant URLs carry credentials."""
|
|
230
|
+
|
|
231
|
+
token = _REDACT_REQUEST_URLS.set(True)
|
|
232
|
+
try:
|
|
233
|
+
yield
|
|
234
|
+
finally:
|
|
235
|
+
_REDACT_REQUEST_URLS.reset(token)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@contextmanager
|
|
239
|
+
def _as_transfer_errors(expires_at: datetime) -> Iterator[None]:
|
|
240
|
+
"""Transport failures in this scope become sanitized ``TransferError``s."""
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
yield
|
|
244
|
+
except httpx.TimeoutException as exc:
|
|
245
|
+
raise TransferError(
|
|
246
|
+
"transfer_timeout",
|
|
247
|
+
"The object transfer timed out.",
|
|
248
|
+
retryable=datetime.now(_UTC) < expires_at,
|
|
249
|
+
) from exc
|
|
250
|
+
except httpx.HTTPError as exc:
|
|
251
|
+
raise TransferError("transfer_unavailable", _UNAVAILABLE) from exc
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@contextmanager
|
|
255
|
+
def _transfer_request(expires_at: datetime) -> Iterator[None]:
|
|
256
|
+
"""One request made with a grant."""
|
|
257
|
+
|
|
258
|
+
with _redacting_request_urls(), _as_transfer_errors(expires_at):
|
|
259
|
+
yield
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _raise_for_transfer_status(response: httpx.Response) -> None:
|
|
263
|
+
if response.is_success:
|
|
264
|
+
return
|
|
265
|
+
if (
|
|
266
|
+
response.status_code in (408, 429)
|
|
267
|
+
or response.status_code >= 500
|
|
268
|
+
or _is_s3_request_timeout(response)
|
|
269
|
+
):
|
|
270
|
+
raise TransferError("transfer_unavailable", _UNAVAILABLE)
|
|
271
|
+
raise TransferError("transfer_rejected", "The object store rejected the transfer.")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_s3_request_timeout(response: httpx.Response) -> bool:
|
|
275
|
+
"""S3 answers an upload that stalled with a 400 whose error code is RequestTimeout."""
|
|
276
|
+
if response.status_code != 400:
|
|
277
|
+
return False
|
|
278
|
+
try:
|
|
279
|
+
return b"<Code>RequestTimeout</Code>" in response.content[:1024]
|
|
280
|
+
except httpx.ResponseNotRead:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _check_unexpired(expires_at: datetime) -> None:
|
|
285
|
+
if expires_at <= datetime.now(_UTC):
|
|
286
|
+
raise TransferError("grant_expired", "The object-transfer grant has expired.")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _retrying(expires_at: datetime, attempt: Callable[[], Awaitable[_T]]) -> _T:
|
|
290
|
+
"""Run ``attempt``, retrying a retryable failure with jittered backoff up to
|
|
291
|
+
three attempts in all, and only while the grant is unexpired."""
|
|
292
|
+
|
|
293
|
+
attempts = 1
|
|
294
|
+
while True:
|
|
295
|
+
_check_unexpired(expires_at)
|
|
296
|
+
try:
|
|
297
|
+
return await attempt()
|
|
298
|
+
except TransferError as exc:
|
|
299
|
+
if (
|
|
300
|
+
attempts == TRANSFER_ATTEMPTS
|
|
301
|
+
or not exc.retryable
|
|
302
|
+
or datetime.now(_UTC) >= expires_at
|
|
303
|
+
):
|
|
304
|
+
raise
|
|
305
|
+
delay = _RETRY_BACKOFF_SECONDS * 2 ** (attempts - 1)
|
|
306
|
+
await asyncio.sleep(random.uniform(delay / 2, delay))
|
|
307
|
+
attempts += 1
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _check_source(source: Path | bytes, max_bytes: int) -> int:
|
|
311
|
+
if isinstance(source, bytes):
|
|
312
|
+
size_bytes = len(source)
|
|
313
|
+
else:
|
|
314
|
+
try:
|
|
315
|
+
size_bytes = source.stat().st_size
|
|
316
|
+
except OSError as exc:
|
|
317
|
+
raise TransferError(
|
|
318
|
+
"invalid_transfer", "The transfer source is unavailable."
|
|
319
|
+
) from exc
|
|
320
|
+
if not source.is_file():
|
|
321
|
+
raise TransferError(
|
|
322
|
+
"invalid_transfer", "The transfer source must be a file."
|
|
323
|
+
)
|
|
324
|
+
if size_bytes > max_bytes:
|
|
325
|
+
raise TransferError("transfer_too_large", _TOO_LARGE)
|
|
326
|
+
return size_bytes
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class _SourceStream:
|
|
330
|
+
"""An open upload source that counts and hashes what it sends and fails unless
|
|
331
|
+
it sends exactly the size declared for the request.
|
|
332
|
+
|
|
333
|
+
It is the async body of a PUT and the file of a multipart POST.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def __init__(self, source: Path | bytes, size_bytes: int) -> None:
|
|
337
|
+
if isinstance(source, bytes):
|
|
338
|
+
self._file: BinaryIO = io.BytesIO(source)
|
|
339
|
+
else:
|
|
340
|
+
try:
|
|
341
|
+
self._file = source.open("rb")
|
|
342
|
+
except OSError as exc:
|
|
343
|
+
raise TransferError("invalid_transfer", _UNREADABLE) from exc
|
|
344
|
+
self._declared_bytes = size_bytes
|
|
345
|
+
self.size_bytes = 0
|
|
346
|
+
self._digest = hashlib.sha256()
|
|
347
|
+
|
|
348
|
+
def __enter__(self) -> _SourceStream:
|
|
349
|
+
return self
|
|
350
|
+
|
|
351
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
352
|
+
self._file.close()
|
|
353
|
+
|
|
354
|
+
def fileno(self) -> int:
|
|
355
|
+
return self._file.fileno()
|
|
356
|
+
|
|
357
|
+
# httpx sizes a multipart file part it cannot fstat by seeking to its end and back, and
|
|
358
|
+
# rewinds it before sending; a rewind restarts the count.
|
|
359
|
+
def tell(self) -> int:
|
|
360
|
+
return self._file.tell()
|
|
361
|
+
|
|
362
|
+
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
|
363
|
+
position = self._file.seek(offset, whence)
|
|
364
|
+
if position == 0:
|
|
365
|
+
self.size_bytes = 0
|
|
366
|
+
self._digest = hashlib.sha256()
|
|
367
|
+
return position
|
|
368
|
+
|
|
369
|
+
def read(self, size: int = -1) -> bytes:
|
|
370
|
+
try:
|
|
371
|
+
chunk = self._file.read(size)
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
raise TransferError("invalid_transfer", _UNREADABLE) from exc
|
|
374
|
+
self.size_bytes += len(chunk)
|
|
375
|
+
if self.size_bytes > self._declared_bytes or (
|
|
376
|
+
not chunk and self.size_bytes < self._declared_bytes
|
|
377
|
+
):
|
|
378
|
+
raise TransferError(
|
|
379
|
+
"invalid_transfer", "The transfer source changed during upload."
|
|
380
|
+
)
|
|
381
|
+
self._digest.update(chunk)
|
|
382
|
+
return chunk
|
|
383
|
+
|
|
384
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
385
|
+
while chunk := await asyncio.to_thread(self.read, _CHUNK_BYTES):
|
|
386
|
+
yield chunk
|
|
387
|
+
|
|
388
|
+
def uploaded(self) -> Uploaded:
|
|
389
|
+
return Uploaded(size_bytes=self.size_bytes, sha256=self._digest.hexdigest())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _as_source(source: Path | str | bytes) -> Path | bytes:
|
|
393
|
+
return source if isinstance(source, bytes) else Path(source)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
async def upload(target: WriteObject, source: Path | bytes) -> Uploaded:
|
|
397
|
+
"""Upload one file, or bytes already in memory, using an opaque exact-object PUT grant."""
|
|
398
|
+
|
|
399
|
+
source = _as_source(source)
|
|
400
|
+
size_bytes = _check_source(source, target.max_bytes)
|
|
401
|
+
headers = httpx.Headers(target.write.headers or {})
|
|
402
|
+
headers.setdefault("content-type", target.media_type)
|
|
403
|
+
headers.setdefault("content-length", str(size_bytes))
|
|
404
|
+
|
|
405
|
+
async def put() -> Uploaded:
|
|
406
|
+
with (
|
|
407
|
+
_SourceStream(source, size_bytes) as stream,
|
|
408
|
+
_transfer_request(target.write.expires_at),
|
|
409
|
+
):
|
|
410
|
+
async with httpx.AsyncClient(
|
|
411
|
+
follow_redirects=False, timeout=_TRANSFER_TIMEOUT
|
|
412
|
+
) as client:
|
|
413
|
+
response = await client.put(
|
|
414
|
+
target.write.url, headers=headers, content=stream
|
|
415
|
+
)
|
|
416
|
+
_raise_for_transfer_status(response)
|
|
417
|
+
return stream.uploaded()
|
|
418
|
+
|
|
419
|
+
return await _retrying(target.write.expires_at, put)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def download(source: ReadObject, destination: Path) -> None:
|
|
423
|
+
"""Download one object atomically, checking the stored bytes against its size
|
|
424
|
+
and checksum; the store is asked not to transcode them."""
|
|
425
|
+
|
|
426
|
+
destination = Path(destination)
|
|
427
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
handle, filename = tempfile.mkstemp(
|
|
429
|
+
dir=destination.parent,
|
|
430
|
+
prefix=f".{destination.name}.",
|
|
431
|
+
suffix=".part",
|
|
432
|
+
)
|
|
433
|
+
os.close(handle)
|
|
434
|
+
temporary = Path(filename)
|
|
435
|
+
headers = httpx.Headers(source.read.headers or {})
|
|
436
|
+
headers["accept-encoding"] = "identity"
|
|
437
|
+
|
|
438
|
+
async def get() -> tuple[int, str]:
|
|
439
|
+
size_bytes = 0
|
|
440
|
+
digest = hashlib.sha256()
|
|
441
|
+
with _transfer_request(source.read.expires_at):
|
|
442
|
+
async with (
|
|
443
|
+
httpx.AsyncClient(
|
|
444
|
+
follow_redirects=False, timeout=_TRANSFER_TIMEOUT
|
|
445
|
+
) as client,
|
|
446
|
+
client.stream("GET", source.read.url, headers=headers) as response,
|
|
447
|
+
):
|
|
448
|
+
_raise_for_transfer_status(response)
|
|
449
|
+
stream = await asyncio.to_thread(temporary.open, "wb")
|
|
450
|
+
try:
|
|
451
|
+
async for chunk in response.aiter_raw(_CHUNK_BYTES):
|
|
452
|
+
size_bytes += len(chunk)
|
|
453
|
+
if size_bytes > source.max_bytes:
|
|
454
|
+
raise TransferError("transfer_too_large", _TOO_LARGE)
|
|
455
|
+
digest.update(chunk)
|
|
456
|
+
await asyncio.to_thread(stream.write, chunk)
|
|
457
|
+
finally:
|
|
458
|
+
await asyncio.to_thread(stream.close)
|
|
459
|
+
return size_bytes, digest.hexdigest()
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
size_bytes, sha256 = await _retrying(source.read.expires_at, get)
|
|
463
|
+
if source.size_bytes is not None and size_bytes != source.size_bytes:
|
|
464
|
+
raise TransferError(
|
|
465
|
+
"integrity_mismatch",
|
|
466
|
+
"The downloaded object size does not match its descriptor.",
|
|
467
|
+
)
|
|
468
|
+
if source.sha256 is not None and sha256 != source.sha256:
|
|
469
|
+
raise TransferError(
|
|
470
|
+
"integrity_mismatch",
|
|
471
|
+
"The downloaded object checksum does not match its descriptor.",
|
|
472
|
+
)
|
|
473
|
+
temporary.replace(destination)
|
|
474
|
+
finally:
|
|
475
|
+
temporary.unlink(missing_ok=True)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _post_to_namespace(
|
|
479
|
+
target: WriteNamespaceGrant, object_path: str, source: Path | bytes, size_bytes: int
|
|
480
|
+
) -> Uploaded:
|
|
481
|
+
"""A multipart POST through the namespace's policy. httpx encodes multipart bodies by
|
|
482
|
+
reading the file synchronously, so this runs on a sync client in a worker thread rather
|
|
483
|
+
than blocking the event loop."""
|
|
484
|
+
grant = target.write
|
|
485
|
+
fields = {**grant.fields, grant.path_field: object_path}
|
|
486
|
+
fields.setdefault("Content-Type", "application/octet-stream")
|
|
487
|
+
with (
|
|
488
|
+
_SourceStream(source, size_bytes) as stream,
|
|
489
|
+
_transfer_request(target.expires_at),
|
|
490
|
+
httpx.Client(follow_redirects=False, timeout=_TRANSFER_TIMEOUT) as client,
|
|
491
|
+
):
|
|
492
|
+
response = client.post(
|
|
493
|
+
grant.url,
|
|
494
|
+
data=fields,
|
|
495
|
+
files={
|
|
496
|
+
grant.file_field: (
|
|
497
|
+
object_path.rsplit("/", 1)[-1],
|
|
498
|
+
stream,
|
|
499
|
+
"application/octet-stream",
|
|
500
|
+
)
|
|
501
|
+
},
|
|
502
|
+
headers=grant.headers,
|
|
503
|
+
)
|
|
504
|
+
_raise_for_transfer_status(response)
|
|
505
|
+
return stream.uploaded()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class NamespaceUploader:
|
|
509
|
+
"""Uploads objects under one namespace grant within its object and byte limits.
|
|
510
|
+
|
|
511
|
+
One instance must make every upload for the grant; it runs them one at a time.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
def __init__(self, target: WriteNamespaceGrant) -> None:
|
|
515
|
+
self.target = target
|
|
516
|
+
self._committed: dict[str, int] = {}
|
|
517
|
+
self._lock = asyncio.Lock()
|
|
518
|
+
|
|
519
|
+
async def upload(self, relative_path: str, source: Path | bytes) -> Uploaded:
|
|
520
|
+
"""Upload or overwrite one relative path under the namespace root."""
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
path = _relative_path(relative_path)
|
|
524
|
+
except ValueError as exc:
|
|
525
|
+
raise TransferError(
|
|
526
|
+
"invalid_transfer", "The namespace upload path is invalid."
|
|
527
|
+
) from exc
|
|
528
|
+
source = _as_source(source)
|
|
529
|
+
async with self._lock:
|
|
530
|
+
size_bytes = _check_source(source, self.target.max_object_bytes)
|
|
531
|
+
prospective = {**self._committed, path: size_bytes}
|
|
532
|
+
if len(prospective) > self.target.max_objects:
|
|
533
|
+
raise TransferError(
|
|
534
|
+
"transfer_too_large",
|
|
535
|
+
"The upload exceeds the namespace object limit.",
|
|
536
|
+
)
|
|
537
|
+
if sum(prospective.values()) > self.target.max_total_bytes:
|
|
538
|
+
raise TransferError(
|
|
539
|
+
"transfer_too_large",
|
|
540
|
+
"The upload exceeds the namespace total-byte limit.",
|
|
541
|
+
)
|
|
542
|
+
uploaded = await _retrying(
|
|
543
|
+
self.target.expires_at,
|
|
544
|
+
partial(
|
|
545
|
+
asyncio.to_thread,
|
|
546
|
+
_post_to_namespace,
|
|
547
|
+
self.target,
|
|
548
|
+
f"{self.target.root_path}/{path}",
|
|
549
|
+
source,
|
|
550
|
+
size_bytes,
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
self._committed[path] = uploaded.size_bytes
|
|
554
|
+
return uploaded
|