sg-python-api 0.1.2__py3-none-any.whl → 0.2.1__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.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.1.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +347 -281
  5. shotgun_api3/_async_driver.py +25 -55
  6. shotgun_api3/_async_httpx2.py +92 -59
  7. shotgun_api3/_async_transport.py +38 -3
  8. shotgun_api3/_capabilities.py +4 -3
  9. shotgun_api3/_client.py +296 -422
  10. shotgun_api3/_config.py +245 -120
  11. shotgun_api3/_connections.py +144 -0
  12. shotgun_api3/_crud.py +25 -250
  13. shotgun_api3/_driver.py +29 -54
  14. shotgun_api3/_execution.py +1 -3
  15. shotgun_api3/_files.py +193 -21
  16. shotgun_api3/_methods.py +59 -411
  17. shotgun_api3/_protocol.py +27 -53
  18. shotgun_api3/_transport.py +82 -407
  19. shotgun_api3/_version.py +7 -2
  20. shotgun_api3/lib/mockgun/__init__.py +20 -7
  21. shotgun_api3/lib/mockgun/_activity.py +510 -0
  22. shotgun_api3/lib/mockgun/_async_client.py +196 -0
  23. shotgun_api3/lib/mockgun/_dates.py +164 -0
  24. shotgun_api3/lib/mockgun/_errors.py +18 -0
  25. shotgun_api3/lib/mockgun/_events.py +1052 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +553 -0
  28. shotgun_api3/lib/mockgun/_links.py +598 -0
  29. shotgun_api3/lib/mockgun/_new_client.py +92 -0
  30. shotgun_api3/lib/mockgun/_presets.py +492 -0
  31. shotgun_api3/lib/mockgun/_query.py +2205 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +463 -0
  33. shotgun_api3/lib/mockgun/_schema.py +172 -0
  34. shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
  35. shotgun_api3/lib/mockgun/_search.py +378 -0
  36. shotgun_api3/lib/mockgun/_server.py +169 -0
  37. shotgun_api3/lib/mockgun/_sessions.py +206 -0
  38. shotgun_api3/lib/mockgun/_site.py +150 -0
  39. shotgun_api3/lib/mockgun/_site_data.py +584 -0
  40. shotgun_api3/lib/mockgun/_storage.py +208 -0
  41. shotgun_api3/lib/mockgun/_store.py +1559 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +965 -0
  43. shotgun_api3/lib/mockgun/_sync_client.py +190 -0
  44. shotgun_api3/lib/mockgun/_threads.py +226 -0
  45. shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
  46. shotgun_api3/lib/mockgun/_transport.py +145 -0
  47. shotgun_api3/lib/mockgun/_uploads.py +417 -0
  48. shotgun_api3/lib/mockgun/_wire.py +120 -0
  49. shotgun_api3/lib/mockgun/mockgun.py +14 -0
  50. shotgun_api3/lib/sgtimezone.py +24 -1
  51. sg_python_api-0.1.2.dist-info/RECORD +0 -31
  52. shotgun_api3/_signatures.py +0 -53
  53. shotgun_api3/lib/mockgun/_async.py +0 -315
  54. shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
  55. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
  56. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -13,7 +13,7 @@ from io import BytesIO
13
13
  from typing import Protocol, TypeVar, cast
14
14
 
15
15
  from . import _protocol, _transport
16
- from ._async_transport import AsyncTransport
16
+ from ._async_transport import AsyncTransport, AsyncUploadObserver
17
17
  from ._config import (
18
18
  EventRoute,
19
19
  ObserverCallback,
@@ -37,7 +37,6 @@ from ._execution import (
37
37
  build_rpc_request,
38
38
  )
39
39
  from ._transport import (
40
- ReadTimeoutError,
41
40
  SendError,
42
41
  TransportError,
43
42
  TransportRequest,
@@ -51,23 +50,15 @@ _HTTP_REDIRECT = 302
51
50
 
52
51
  @dataclasses.dataclass(frozen=True)
53
52
  class AsyncAttemptControl:
54
- """Consume async attempt bytes under deadline and progress control."""
53
+ """Consume async attempt bytes with progress reporting."""
55
54
 
56
55
  _attempt: int
57
- _clock: Callable[[], float]
58
- _deadline: float | None
59
56
  _observer: ObserverCallback | None
60
57
  _operation: _protocol.OperationCategory
61
58
  _route: EventRoute
62
59
 
63
- def _check_deadline(self) -> None:
64
- """Raise when cumulative monotonic attempt time exceeds its limit."""
65
- if self._deadline is not None and self._clock() > self._deadline:
66
- raise ReadTimeoutError('total attempt timeout exceeded')
67
-
68
- async def _consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
60
+ async def consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
69
61
  """Observe a received buffered body without rebuilding it."""
70
- self._check_deadline()
71
62
  if body:
72
63
  notify_observer(
73
64
  self._observer,
@@ -81,22 +72,13 @@ class AsyncAttemptControl:
81
72
  )
82
73
  return body
83
74
 
84
- def _remaining_timeout(self) -> float | None:
85
- """Return the remaining cumulative attempt timeout."""
86
- if self._deadline is None:
87
- return None
88
- remaining = self._deadline - self._clock()
89
- if remaining < 0:
90
- raise ReadTimeoutError('total attempt timeout exceeded')
91
- return remaining
92
-
93
75
  async def consume_into(
94
76
  self,
95
77
  chunks: AsyncIterator[bytes],
96
78
  sink: Callable[[bytes], object | Awaitable[object]],
97
79
  total_bytes: int | None = None,
98
80
  ) -> None:
99
- """Write async chunks under deadline and progress control.
81
+ """Write async chunks with progress reporting.
100
82
 
101
83
  Args:
102
84
  chunks: Fresh response-body chunks for this attempt.
@@ -104,8 +86,7 @@ class AsyncAttemptControl:
104
86
  total_bytes: Known complete body size, when available.
105
87
 
106
88
  Raises:
107
- ReadTimeoutError: If cumulative attempt time exceeds the total
108
- limit.
89
+ Exception: If reading a chunk or invoking the sink fails.
109
90
  """
110
91
  transferred = 0
111
92
  async for chunk in chunks:
@@ -113,7 +94,6 @@ class AsyncAttemptControl:
113
94
  if inspect.isawaitable(sink_result):
114
95
  await sink_result
115
96
  transferred += len(chunk)
116
- self._check_deadline()
117
97
  if chunk:
118
98
  notify_observer(
119
99
  self._observer,
@@ -131,7 +111,7 @@ class AsyncAttemptControl:
131
111
  chunks: AsyncIterator[bytes],
132
112
  total_bytes: int | None = None,
133
113
  ) -> bytes:
134
- """Collect async chunks while enforcing the attempt deadline.
114
+ """Collect async chunks while reporting progress.
135
115
 
136
116
  Args:
137
117
  chunks: Fresh response-body chunks for this attempt.
@@ -141,8 +121,7 @@ class AsyncAttemptControl:
141
121
  Concatenated response bytes.
142
122
 
143
123
  Raises:
144
- ReadTimeoutError: If cumulative attempt time exceeds the total
145
- limit.
124
+ Exception: If reading a chunk fails.
146
125
  """
147
126
  body = BytesIO()
148
127
  await self.consume_into(chunks, body.write, total_bytes=total_bytes)
@@ -239,11 +218,8 @@ class AsyncDriver:
239
218
  ) -> AttemptState[AsyncAttemptControl]:
240
219
  """Build immutable timing and observer state for one fresh attempt."""
241
220
  started = self._clock()
242
- deadline = None if context.timeout.total is None else started + context.timeout.total
243
221
  control = AsyncAttemptControl(
244
222
  _attempt=attempt,
245
- _clock=self._clock,
246
- _deadline=deadline,
247
223
  _observer=context.on_progress,
248
224
  _operation=operation.category,
249
225
  _route=route,
@@ -296,7 +272,6 @@ class AsyncDriver:
296
272
  response_status = response.status
297
273
  response_headers = response.headers
298
274
  correlation_id = response_correlation_id(response_headers)
299
- state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
300
275
  failure = _protocol.classify_http_failure(
301
276
  response_status,
302
277
  response_headers,
@@ -416,7 +391,6 @@ class AsyncDriver:
416
391
  request: TransportRequest,
417
392
  state: AttemptState[AsyncAttemptControl],
418
393
  *,
419
- redirected: bool,
420
394
  streaming: bool,
421
395
  ) -> AsyncLiveResponse | AttemptRetry:
422
396
  """Send once and close the request body under response ownership."""
@@ -425,8 +399,6 @@ class AsyncDriver:
425
399
  response_status: int | None = None
426
400
  try:
427
401
  try:
428
- if redirected:
429
- request = _request_with_remaining_timeout(request, state.control)
430
402
  if streaming:
431
403
  response = await self._transport.send_streaming(request)
432
404
  else:
@@ -496,7 +468,6 @@ class AsyncDriver:
496
468
  sent = await self._send_request(
497
469
  request,
498
470
  state,
499
- redirected=redirected,
500
471
  streaming=execution_options.streaming,
501
472
  )
502
473
  if isinstance(sent, AttemptRetry):
@@ -561,7 +532,7 @@ class AsyncDriver:
561
532
  buffered.headers,
562
533
  buffered.content,
563
534
  )
564
- body = await control._consume_buffered( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
535
+ body = await control.consume_buffered(
565
536
  buffered.content,
566
537
  total_bytes=len(buffered.content),
567
538
  )
@@ -636,6 +607,11 @@ class AsyncDriver:
636
607
 
637
608
  Yields:
638
609
  Decoded RPC results in page order.
610
+
611
+ Raises:
612
+ TransportError: If a page exhausts its transport attempts.
613
+ Exception: If building, validating, decoding, or cleaning up a page
614
+ operation fails.
639
615
  """
640
616
  if context.page_prefetch <= 0:
641
617
  page = first_page
@@ -686,6 +662,7 @@ class AsyncDriver:
686
662
  Raises:
687
663
  ValueError: If `attempts` is not positive or `interval` is
688
664
  negative.
665
+ Exception: If the predicate or configured sleep fails.
689
666
  """
690
667
  if attempts <= 0:
691
668
  raise ValueError('poll attempts must be positive')
@@ -721,7 +698,7 @@ class _AsyncBufferedResponse(AsyncLiveResponse, Protocol):
721
698
  ...
722
699
 
723
700
 
724
- class _ObservedRequestBody(Iterator[bytes]):
701
+ class _ObservedRequestBody(Iterator[bytes], AsyncUploadObserver):
725
702
  """Publish progress while owning exact-once request-body cleanup."""
726
703
 
727
704
  def __init__(
@@ -753,7 +730,13 @@ class _ObservedRequestBody(Iterator[bytes]):
753
730
  return chunk
754
731
 
755
732
  def close(self) -> None:
756
- """Close the source iterator exactly once."""
733
+ """Close the source iterator at most once.
734
+
735
+ Later calls do not repeat cleanup even when the first close fails.
736
+
737
+ Raises:
738
+ Exception: If the source iterator's cleanup fails.
739
+ """
757
740
  with self._condition:
758
741
  if self._closed:
759
742
  return
@@ -781,15 +764,10 @@ class _ObservedRequestBody(Iterator[bytes]):
781
764
 
782
765
  Args:
783
766
  chunk: Chunk pulled from the source iterator.
784
-
785
- Raises:
786
- ReadTimeoutError: If the attempt deadline passed before the chunk
787
- could be returned.
788
767
  """
789
768
  self._transferred += len(chunk)
790
769
  if not chunk:
791
770
  return
792
- self._state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
793
771
  notify_observer(
794
772
  self._state.context.on_progress,
795
773
  ProgressEvent(
@@ -807,6 +785,9 @@ class _ObservedRequestBody(Iterator[bytes]):
807
785
  Returns:
808
786
  The next chunk, or `None` when the source is exhausted or the body
809
787
  is closing or closed.
788
+
789
+ Raises:
790
+ Exception: If the source iterator fails.
810
791
  """
811
792
  with self._condition:
812
793
  chunks = self._chunks
@@ -890,17 +871,6 @@ def _request_with_progress(
890
871
  )
891
872
 
892
873
 
893
- def _request_with_remaining_timeout(
894
- request: TransportRequest,
895
- control: AsyncAttemptControl,
896
- ) -> TransportRequest:
897
- """Restrict a redirect hop to its remaining cumulative attempt timeout."""
898
- return _transport.narrow_total_timeout(
899
- request,
900
- control._remaining_timeout(), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
901
- )
902
-
903
-
904
874
  def _retain_detached_cleanup(task: asyncio.Task[None]) -> None:
905
875
  """Retain a detached cleanup task until its completion is observed."""
906
876
  _DETACHED_CLEANUP_TASKS.add(task)
@@ -3,14 +3,15 @@
3
3
  import asyncio
4
4
  import dataclasses
5
5
  import ssl
6
- from collections.abc import AsyncIterator, Callable, Iterator
6
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
7
7
  from contextlib import suppress
8
8
  from http.cookiejar import CookieJar, DefaultCookiePolicy
9
- from typing import Self, cast
9
+ from typing import Self, TypedDict, cast
10
10
 
11
11
  import httpx2
12
12
 
13
- from ._async_transport import AsyncStreamingResponse
13
+ from ._async_transport import AsyncStreamingResponse, AsyncUploadObserver
14
+ from ._connections import SocketOption, keepalive_socket_options, tune_socket
14
15
  from ._transport import (
15
16
  CaseInsensitiveHeaders,
16
17
  ConnectError,
@@ -37,12 +38,15 @@ class Httpx2Transport:
37
38
  closes the transport.
38
39
  """
39
40
 
40
- def __init__(
41
+ def __init__( # noqa: PLR0913
41
42
  self,
42
43
  ca_certs: str | None = None,
43
44
  proxy_url: str | None = None,
44
45
  proxy_headers: dict[str, str] | None = None,
45
46
  pool_maxsize: int = 10,
47
+ *,
48
+ max_connection_idle_secs: float | None = 60.0,
49
+ tcp_keepalive_timeout_secs: int | None = 60,
46
50
  ) -> None:
47
51
  """Initialize one shared async HTTP transport.
48
52
 
@@ -51,6 +55,14 @@ class Httpx2Transport:
51
55
  proxy_url: Optional proxy URL.
52
56
  proxy_headers: Optional headers sent to the proxy.
53
57
  pool_maxsize: Maximum concurrent and keep-alive connections.
58
+ max_connection_idle_secs: Validated idle limit in seconds, or
59
+ `None` to disable expiry.
60
+ tcp_keepalive_timeout_secs: Validated nominal keepalive target in
61
+ seconds, or `None` to leave sockets untuned.
62
+
63
+ Raises:
64
+ OSError: If the certificate-authority bundle cannot be read or
65
+ loaded into the SSL context.
54
66
  """
55
67
  verify: ssl.SSLContext | bool = True
56
68
  if ca_certs is not None:
@@ -66,9 +78,14 @@ class Httpx2Transport:
66
78
  limits=httpx2.Limits(
67
79
  max_connections=pool_maxsize,
68
80
  max_keepalive_connections=pool_maxsize,
81
+ keepalive_expiry=max_connection_idle_secs,
69
82
  ),
70
83
  timeout=None,
71
84
  )
85
+ self._request_options: _TraceRequestOptions = {}
86
+ if tcp_keepalive_timeout_secs is not None:
87
+ options = keepalive_socket_options(tcp_keepalive_timeout_secs)
88
+ self._request_options['extensions'] = {'trace': _keepalive_trace(options)}
72
89
  self._active = 0
73
90
  self._body_cleanup_tasks: set[asyncio.Task[None]] = set()
74
91
  self._closed = False
@@ -121,18 +138,12 @@ class Httpx2Transport:
121
138
  async def _open_stream(
122
139
  self,
123
140
  request: httpx2.Request,
124
- deadline: float | None,
125
141
  ) -> httpx2.Response:
126
- """Send through the response-header phase under the total deadline."""
142
+ """Send through the response-header phase and map backend errors."""
127
143
  try:
128
- if deadline is None:
129
- return await self._client.send(request, stream=True)
130
- async with asyncio.timeout_at(deadline):
131
- return await self._client.send(request, stream=True)
144
+ return await self._client.send(request, stream=True)
132
145
  except TimeoutError as error:
133
- if deadline is None:
134
- raise SendError(str(error)) from error
135
- raise ReadTimeoutError('total timeout exceeded before response body') from error
146
+ raise SendError(str(error)) from error
136
147
  except (httpx2.HTTPError, httpx2.StreamError) as error:
137
148
  raise _map_httpx2_error(error) from error
138
149
 
@@ -219,6 +230,7 @@ class Httpx2Transport:
219
230
  headers=request.headers,
220
231
  content=body,
221
232
  timeout=_to_httpx2_timeout(request.timeout),
233
+ **self._request_options,
222
234
  )
223
235
  except BaseException:
224
236
  self._retain_body_cleanup(body)
@@ -258,7 +270,8 @@ class Httpx2Transport:
258
270
 
259
271
  Raises:
260
272
  TransportError: If the current task owns a lease whose release
261
- would depend on this call returning.
273
+ would depend on this call returning, or backend pool cleanup
274
+ fails.
262
275
  """
263
276
  owner = _current_task()
264
277
  if self._has_lease(owner) or self._stream_owners.get(owner, 0) > 0:
@@ -295,22 +308,13 @@ class Httpx2Transport:
295
308
  TransportError: If httpx2 cannot complete the request.
296
309
  """
297
310
  self._require_send_available(_current_task())
298
- total = request.timeout.total
299
- if total is None:
300
- try:
301
- return await self._send_once(request)
302
- except (httpx2.HTTPError, httpx2.StreamError) as error:
303
- raise _map_httpx2_error(error) from error
304
311
  try:
305
- async with asyncio.timeout(request.timeout.total):
306
- return await self._send_once(request)
307
- except TimeoutError as error:
308
- raise ReadTimeoutError('total timeout exceeded') from error
312
+ return await self._send_once(request)
309
313
  except (httpx2.HTTPError, httpx2.StreamError) as error:
310
314
  raise _map_httpx2_error(error) from error
311
315
 
312
316
  async def send_streaming(self, request: TransportRequest) -> AsyncStreamingResponse:
313
- """Send one request and stream its body under one total deadline.
317
+ """Send one request and stream its body.
314
318
 
315
319
  Args:
316
320
  request: Request expressed in shared transport types.
@@ -319,13 +323,10 @@ class Httpx2Transport:
319
323
  A single-use streaming response in shared transport types.
320
324
 
321
325
  Raises:
322
- TransportError: If the request, response, or total deadline fails.
326
+ TransportError: If the request or response fails.
323
327
  """
324
328
  current_task = _current_task()
325
329
  self._require_send_available(current_task)
326
- deadline = None
327
- if request.timeout.total is not None:
328
- deadline = asyncio.get_running_loop().time() + request.timeout.total
329
330
  body = _adapt_body(request.body)
330
331
  try:
331
332
  outgoing = self._client.build_request(
@@ -334,6 +335,7 @@ class Httpx2Transport:
334
335
  headers=request.headers,
335
336
  content=body,
336
337
  timeout=_to_httpx2_timeout(request.timeout),
338
+ **self._request_options,
337
339
  )
338
340
  except (httpx2.HTTPError, httpx2.StreamError) as error:
339
341
  self._retain_body_cleanup(body)
@@ -344,7 +346,7 @@ class Httpx2Transport:
344
346
 
345
347
  owner = self._begin_stream()
346
348
  try:
347
- response = await self._open_stream(outgoing, deadline)
349
+ response = await self._open_stream(outgoing)
348
350
  except BaseException:
349
351
  self._retain_body_cleanup(body)
350
352
  self._release_stream(owner)
@@ -359,7 +361,6 @@ class Httpx2Transport:
359
361
 
360
362
  body_owner = _ResponseBody(
361
363
  response,
362
- deadline,
363
364
  lambda: self._release_stream(owner),
364
365
  lambda task: self._reserve_stream_task(task, owner),
365
366
  )
@@ -387,12 +388,10 @@ class _ResponseBody:
387
388
  def __init__(
388
389
  self,
389
390
  response: httpx2.Response,
390
- deadline: float | None,
391
391
  release: Callable[[], None],
392
392
  reserve: Callable[[asyncio.Task[object]], None],
393
393
  ) -> None:
394
- """Initialize one deadline-aware async response body."""
395
- self._deadline = deadline
394
+ """Initialize one async response body."""
396
395
  self._pending: asyncio.Task[bytes] | None = None
397
396
  self._release = release
398
397
  self._reserve = reserve
@@ -429,7 +428,7 @@ class _ResponseBody:
429
428
  """Run one retained response pull behind a shielded consumer wait."""
430
429
  if self._pending is not None:
431
430
  raise TransportError('async response already has an active pull')
432
- pull = asyncio.create_task(_next_response_chunk(iterator, self._deadline))
431
+ pull = asyncio.create_task(_next_response_chunk(iterator))
433
432
  self._pending = pull
434
433
  try:
435
434
  self._reserve(pull)
@@ -452,7 +451,14 @@ class _ResponseBody:
452
451
  self._pending = None
453
452
 
454
453
  async def close(self) -> None:
455
- """Wait for cleanup unless the calling task is cancelling."""
454
+ """Wait for cleanup unless the calling task is cancelling.
455
+
456
+ An already-cancelling caller leaves the retained cleanup task running
457
+ without waiting for it.
458
+
459
+ Raises:
460
+ TransportError: If awaited backend response cleanup fails.
461
+ """
456
462
  cleanup = self._start()
457
463
  current = asyncio.current_task()
458
464
  if current is not None and current.cancelling():
@@ -460,7 +466,7 @@ class _ResponseBody:
460
466
  await asyncio.shield(cleanup)
461
467
 
462
468
  async def stream(self, size: int) -> AsyncIterator[bytes]:
463
- """Yield mapped, deadline-aware response chunks.
469
+ """Yield response chunks with mapped backend errors.
464
470
 
465
471
  Args:
466
472
  size: Maximum preferred chunk size in bytes.
@@ -469,7 +475,7 @@ class _ResponseBody:
469
475
  Response body chunks.
470
476
 
471
477
  Raises:
472
- TransportError: If reading the response stream fails.
478
+ TransportError: If reading or closing the response stream fails.
473
479
  """
474
480
  try:
475
481
  iterator = self._response.aiter_bytes(size)
@@ -505,7 +511,7 @@ class _ThreadedBody(AsyncIterator[bytes]):
505
511
  try:
506
512
  pulled = await asyncio.shield(pull)
507
513
  except asyncio.CancelledError:
508
- self._start_close()
514
+ self.start_close()
509
515
  raise
510
516
  except Exception as error:
511
517
  raise SendError(str(error)) from error
@@ -528,7 +534,7 @@ class _ThreadedBody(AsyncIterator[bytes]):
528
534
  await asyncio.shield(pending)
529
535
  await _close_body(self._chunks)
530
536
 
531
- def _start_close(self) -> asyncio.Task[None]:
537
+ def start_close(self) -> asyncio.Task[None]:
532
538
  """Start idempotent cleanup and retain its task."""
533
539
  if self._close_task is None:
534
540
  self._close_task = asyncio.create_task(self._finish_close())
@@ -536,14 +542,27 @@ class _ThreadedBody(AsyncIterator[bytes]):
536
542
  return self._close_task
537
543
 
538
544
  async def aclose(self) -> None:
539
- """Close the underlying iterator exactly once and off-loop."""
540
- close_task = self._start_close()
545
+ """Close the underlying iterator exactly once and off-loop.
546
+
547
+ An already-cancelling caller leaves the retained cleanup task running
548
+ without waiting for it.
549
+
550
+ Raises:
551
+ SendError: If awaited iterator cleanup fails.
552
+ """
553
+ close_task = self.start_close()
541
554
  current = asyncio.current_task()
542
555
  if current is not None and current.cancelling():
543
556
  return
544
557
  await asyncio.shield(close_task)
545
558
 
546
559
 
560
+ class _TraceRequestOptions(TypedDict, total=False):
561
+ """Omit the trace extension entirely when socket tuning is disabled."""
562
+
563
+ extensions: dict[str, Callable[[str, Mapping[str, object]], Awaitable[None]]]
564
+
565
+
547
566
  def _adapt_body(
548
567
  body: bytes | Iterator[bytes] | None,
549
568
  ) -> bytes | _ThreadedBody | None:
@@ -595,6 +614,24 @@ def _is_tls_failure(error: BaseException) -> bool:
595
614
  return False
596
615
 
597
616
 
617
+ def _keepalive_trace(
618
+ options: tuple[SocketOption, ...],
619
+ ) -> Callable[[str, Mapping[str, object]], Awaitable[None]]:
620
+ """Tune the socket exposed by each completed TCP connection trace event."""
621
+
622
+ async def trace(event: str, info: Mapping[str, object]) -> None:
623
+ if event != 'connection.connect_tcp.complete':
624
+ return
625
+ get_extra_info = getattr(info['return_value'], 'get_extra_info', None)
626
+ if get_extra_info is None:
627
+ return
628
+ sock = get_extra_info('socket')
629
+ if sock is not None:
630
+ tune_socket(sock, options)
631
+
632
+ return trace
633
+
634
+
598
635
  def _map_httpx2_error(error: httpx2.HTTPError | httpx2.StreamError) -> TransportError:
599
636
  """Map an httpx2 failure into the shared transport vocabulary."""
600
637
  if isinstance(error, httpx2.ProxyError):
@@ -604,38 +641,34 @@ def _map_httpx2_error(error: httpx2.HTTPError | httpx2.StreamError) -> Transport
604
641
  return TLSError(str(error))
605
642
  return ConnectError(str(error))
606
643
  if isinstance(error, httpx2.PoolTimeout):
607
- return ConnectError(str(error))
644
+ return ConnectError(str(error) or 'timed out waiting for a connection from the pool')
608
645
  if isinstance(error, httpx2.ReadTimeout):
609
646
  return ReadTimeoutError(str(error))
610
647
  return SendError(str(error))
611
648
 
612
649
 
613
650
  def _next_chunk(chunks: Iterator[bytes]) -> _PulledChunk:
614
- """Pull one chunk and capture an exact-name loop observer when supported."""
615
- pull_for_async = getattr(chunks, 'pull_for_async', None)
616
- observe_async_chunk = getattr(chunks, 'observe_async_chunk', None)
617
- if callable(pull_for_async) and callable(observe_async_chunk):
651
+ """Pull one chunk and capture its event-loop observer when supported."""
652
+ if (
653
+ isinstance(chunks, AsyncUploadObserver)
654
+ and callable(chunks.pull_for_async)
655
+ and callable(chunks.observe_async_chunk)
656
+ ):
618
657
  return _PulledChunk(
619
- chunk=cast('Callable[[], bytes | None]', pull_for_async)(),
620
- observe=cast('Callable[[bytes], None]', observe_async_chunk),
658
+ chunk=chunks.pull_for_async(),
659
+ observe=chunks.observe_async_chunk,
621
660
  )
622
661
  return _PulledChunk(chunk=next(chunks, None), observe=None)
623
662
 
624
663
 
625
664
  async def _next_response_chunk(
626
665
  iterator: AsyncIterator[bytes],
627
- deadline: float | None,
628
666
  ) -> bytes:
629
- """Read one response chunk against the remaining total budget."""
667
+ """Read one response chunk and map backend errors."""
630
668
  try:
631
- if deadline is None:
632
- return await anext(iterator)
633
- async with asyncio.timeout_at(deadline):
634
- return await anext(iterator)
669
+ return await anext(iterator)
635
670
  except TimeoutError as error:
636
- if deadline is None:
637
- raise SendError(str(error)) from error
638
- raise ReadTimeoutError('total timeout exceeded during response body') from error
671
+ raise SendError(str(error)) from error
639
672
  except (httpx2.HTTPError, httpx2.StreamError) as error:
640
673
  current = asyncio.current_task()
641
674
  if current is not None and current.cancelling():
@@ -648,7 +681,7 @@ def _start_adapted_body_cleanup(
648
681
  ) -> asyncio.Task[None] | None:
649
682
  """Start and return request-body cleanup without delaying an error path."""
650
683
  if isinstance(body, _ThreadedBody):
651
- return body._start_close() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
684
+ return body.start_close()
652
685
  return None
653
686
 
654
687
 
@@ -1,9 +1,10 @@
1
1
  """Async transport vocabulary and streaming response handling."""
2
2
 
3
3
  import asyncio
4
+ from abc import abstractmethod
4
5
  from collections.abc import AsyncIterator, Awaitable, Callable
5
6
  from contextlib import suppress
6
- from typing import Protocol
7
+ from typing import Protocol, runtime_checkable
7
8
 
8
9
  from ._transport import (
9
10
  CaseInsensitiveHeaders,
@@ -57,7 +58,14 @@ class AsyncStreamingResponse:
57
58
  raise TransportError('streaming response is closed')
58
59
 
59
60
  async def close(self) -> None:
60
- """Release resources associated with the streaming response."""
61
+ """Run the response cleanup callback at most once.
62
+
63
+ The callback is cleared before invocation, so a failed cleanup is not
64
+ retried by later calls.
65
+
66
+ Raises:
67
+ Exception: If the supplied cleanup callback fails.
68
+ """
61
69
  close_fn = self._close_fn
62
70
  self._close_fn = None
63
71
  if close_fn is not None:
@@ -98,10 +106,37 @@ class AsyncStreamingResponse:
98
106
  return self._iterate(size)
99
107
 
100
108
 
109
+ @runtime_checkable
110
+ class AsyncUploadObserver(Protocol):
111
+ """Separate worker-thread upload reads from event-loop progress callbacks.
112
+
113
+ Implementations pull chunks without observing them. The async transport
114
+ invokes the observer on its event loop after each worker-thread pull.
115
+ """
116
+
117
+ @abstractmethod
118
+ def observe_async_chunk(self, chunk: bytes) -> None:
119
+ """Account for one uploaded chunk on the event loop.
120
+
121
+ Args:
122
+ chunk: Chunk returned by `pull_for_async()`.
123
+ """
124
+ ...
125
+
126
+ @abstractmethod
127
+ def pull_for_async(self) -> bytes | None:
128
+ """Pull a chunk without invoking its progress observer.
129
+
130
+ Returns:
131
+ The next chunk, or `None` when no more chunks are available.
132
+ """
133
+ ...
134
+
135
+
101
136
  class AsyncTransport(Protocol):
102
137
  """HTTP backend operations required by the async driver.
103
138
 
104
- Task cancellation replaces the sync transport's `abort` operation. The
139
+ Task cancellation interrupts asynchronous transport operations. The
105
140
  synchronous lease pair brackets one complete logical RPC so draining close
106
141
  operations cannot interrupt a retry loop between attempts.
107
142
  """
@@ -18,10 +18,11 @@ class ClientCapabilities:
18
18
  """Represent local platform details used by compatibility helpers.
19
19
 
20
20
  Attributes:
21
- platform: Operating-system name.
22
- py_version: Python version.
21
+ platform: `linux`, `mac`, or `windows`, or `None` on other platforms.
22
+ py_version: Python version as a `major.minor` string.
23
23
  ssl_version: SSL library version.
24
- local_path_field: Platform-specific local-file field name.
24
+ local_path_field: `local_path_<platform>`, or `None` when the platform
25
+ has no supported local-file field.
25
26
  """
26
27
 
27
28
  def __init__(self) -> None: