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.
@@ -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