contree-client 0.2.1.dev0__tar.gz → 0.3.0__tar.gz

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 (46) hide show
  1. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/PKG-INFO +1 -1
  2. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/__init__.py +10 -0
  3. contree_client-0.3.0/contree_client/aiohttp.py +190 -0
  4. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/base.py +156 -26
  5. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/exceptions.py +32 -3
  6. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/http.py +30 -2
  7. contree_client-0.3.0/contree_client/httpx.py +281 -0
  8. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/models.py +19 -8
  9. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/operations.py +5 -4
  10. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/requests.py +71 -25
  11. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/runtime.py +13 -4
  12. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/spec_info.py +15 -6
  13. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/urllib3.py +66 -29
  14. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/pyproject.toml +1 -1
  15. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/pyproject.toml.orig +1 -1
  16. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/stub_server.py +45 -0
  17. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_keepalive.py +5 -1
  18. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_operation_helpers.py +49 -0
  19. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_runtime.py +14 -0
  20. contree_client-0.3.0/tests/test_transport_error_hierarchy.py +218 -0
  21. contree_client-0.2.1.dev0/contree_client/aiohttp.py +0 -159
  22. contree_client-0.2.1.dev0/contree_client/httpx.py +0 -230
  23. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/LICENSE +0 -0
  24. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/README.md +0 -0
  25. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/asyncio.py +0 -0
  26. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/profiles.py +0 -0
  27. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/py.typed +0 -0
  28. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/sync.py +0 -0
  29. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/testing.py +0 -0
  30. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/types.py +0 -0
  31. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/conftest.py +0 -0
  32. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_aiohttp_payload_interruption.py +0 -0
  33. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_autodetect.py +0 -0
  34. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_clients.py +0 -0
  35. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_file_helpers.py +0 -0
  36. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_integration.py +0 -0
  37. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_interface.py +0 -0
  38. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_logging.py +0 -0
  39. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_models.py +0 -0
  40. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_operations.py +0 -0
  41. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_pagination.py +0 -0
  42. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_profiles.py +0 -0
  43. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_retry.py +0 -0
  44. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_sse.py +0 -0
  45. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_stream_lifecycle.py +0 -0
  46. {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contree-client
3
- Version: 0.2.1.dev0
3
+ Version: 0.3.0
4
4
  Summary: Python client for the Contree API, generated from the OpenAPI specification
5
5
  Author: Dmitry Orlov
6
6
  Author-email: Dmitry Orlov <mosquito@nebius.com>
@@ -25,7 +25,12 @@ from .exceptions import (
25
25
  BadRequestError,
26
26
  ConflictError,
27
27
  ContreeAPIError,
28
+ ContreeConnectionError,
28
29
  ContreeError,
30
+ ContreeHTTPError,
31
+ ContreeStreamError,
32
+ ContreeTimeoutError,
33
+ ContreeTransportError,
29
34
  ForbiddenError,
30
35
  GoneError,
31
36
  NotFoundError,
@@ -115,8 +120,13 @@ __all__ = [
115
120
  "ClosableStreamRepr",
116
121
  "ConflictError",
117
122
  "ContreeAPIError",
123
+ "ContreeConnectionError",
118
124
  "ContreeError",
125
+ "ContreeHTTPError",
119
126
  "ContreeModel",
127
+ "ContreeStreamError",
128
+ "ContreeTimeoutError",
129
+ "ContreeTransportError",
120
130
  "DirectoryList",
121
131
  "Error",
122
132
  "EventData",
@@ -0,0 +1,190 @@
1
+ """Contree API client backed by aiohttp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import gzip
7
+ import ssl
8
+ from collections.abc import AsyncGenerator
9
+ from contextlib import suppress
10
+
11
+ import aiohttp
12
+
13
+ from . import base
14
+ from .exceptions import ContreeConnectionError, ContreeStreamError, ContreeTimeoutError
15
+ from .runtime import (
16
+ CHUNK_SIZE,
17
+ RequestSpec,
18
+ ResponseData,
19
+ RetryPolicy,
20
+ error_for_response,
21
+ library_version,
22
+ )
23
+ from .spec_info import DEFAULT_BASE_URL
24
+ from .types import logger
25
+
26
+
27
+ class ContreeAiohttpConnectionError(
28
+ ContreeConnectionError, aiohttp.ClientConnectionError
29
+ ):
30
+ """A `ContreeConnectionError` that is also an `aiohttp.ClientConnectionError`."""
31
+
32
+
33
+ class ContreeAiohttpTimeoutError(ContreeTimeoutError, TimeoutError):
34
+ """A `ContreeTimeoutError` that is also a stdlib `TimeoutError`."""
35
+
36
+
37
+ class ContreeAiohttpStreamError(ContreeStreamError, aiohttp.ClientPayloadError):
38
+ """A `ContreeStreamError` that is also an `aiohttp.ClientPayloadError`."""
39
+
40
+
41
+ class ContreeAsyncClient(base.ContreeAsyncClient):
42
+ """Asynchronous Contree API client on top of `aiohttp.ClientSession`.
43
+
44
+ The session is created lazily on the first request so the client
45
+ may be constructed outside of a running event loop.
46
+ """
47
+
48
+ log = logger.getChild("aiohttp")
49
+ UA_TRANSPORT_LIBRARY = library_version(aiohttp)
50
+ retryable_errors = (
51
+ aiohttp.ClientConnectionError,
52
+ aiohttp.ServerConnectionError,
53
+ aiohttp.ClientPayloadError,
54
+ )
55
+ nonretryable_errors = (TimeoutError, asyncio.TimeoutError)
56
+
57
+ def __init__(
58
+ self,
59
+ token: str,
60
+ *,
61
+ base_url: str = DEFAULT_BASE_URL,
62
+ project: str | None = None,
63
+ timeout: float | None = 300.0,
64
+ retry: RetryPolicy | None = None,
65
+ identity: str | None = None,
66
+ ssl_context: ssl.SSLContext | None = None,
67
+ # adapter-specific, prefixed by adapter name
68
+ aiohttp_session: aiohttp.ClientSession | None = None,
69
+ ) -> None:
70
+ super().__init__(
71
+ token,
72
+ base_url=base_url,
73
+ project=project,
74
+ timeout=timeout,
75
+ retry=retry,
76
+ identity=identity,
77
+ )
78
+ if aiohttp_session is not None and ssl_context is not None:
79
+ raise ValueError(
80
+ "ssl_context cannot be combined with aiohttp_session;"
81
+ " configure TLS on the session connector itself"
82
+ )
83
+ self.__owns_session = aiohttp_session is None
84
+ self._session = aiohttp_session
85
+ self._ssl_context = ssl_context
86
+
87
+ def _get_session(self) -> aiohttp.ClientSession:
88
+ if self._session is None:
89
+ connector = (
90
+ aiohttp.TCPConnector(ssl=self._ssl_context)
91
+ if self._ssl_context is not None
92
+ else None
93
+ )
94
+ self._session = aiohttp.ClientSession(connector=connector)
95
+ return self._session
96
+
97
+ async def open(self) -> None:
98
+ """Create the session eagerly (`async with client:` path)."""
99
+ self._get_session()
100
+
101
+ async def request(self, spec: RequestSpec) -> ResponseData:
102
+ url = self.build_url(spec)
103
+ try:
104
+ async with self._get_session().request(
105
+ spec.method,
106
+ url,
107
+ data=spec.body,
108
+ headers=self.build_headers(spec),
109
+ allow_redirects=False,
110
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
111
+ ) as response:
112
+ body = await response.read()
113
+ return ResponseData(
114
+ status=response.status,
115
+ headers={k.lower(): v for k, v in response.headers.items()},
116
+ body=body,
117
+ )
118
+ except self.nonretryable_errors as exc:
119
+ raise ContreeAiohttpTimeoutError.wrap(exc) from exc
120
+ except aiohttp.ClientPayloadError as exc:
121
+ raise ContreeAiohttpStreamError.wrap(exc) from exc
122
+ except self.retryable_errors as exc:
123
+ raise ContreeAiohttpConnectionError.wrap(exc) from exc
124
+
125
+ async def stream(
126
+ self,
127
+ spec: RequestSpec,
128
+ auto_decompress: bool = True,
129
+ ) -> AsyncGenerator[bytes, None]:
130
+ url = self.build_url(spec)
131
+ try:
132
+ async with self._get_session().request(
133
+ spec.method,
134
+ url,
135
+ data=spec.body,
136
+ headers=self.build_headers(spec),
137
+ allow_redirects=False,
138
+ auto_decompress=auto_decompress,
139
+ timeout=aiohttp.ClientTimeout(
140
+ total=None,
141
+ sock_connect=self.timeout,
142
+ # only SSE may idle (bounded by spec.read_timeout
143
+ # when a deadline is set); downloads must time out
144
+ sock_read=(
145
+ spec.read_timeout
146
+ if spec.accept == "text/event-stream"
147
+ else self.timeout
148
+ ),
149
+ ),
150
+ ) as response:
151
+ self.log.debug(
152
+ "%s %s -> %d (stream)", spec.method, url, response.status
153
+ )
154
+ if not 200 <= response.status < 300:
155
+ try:
156
+ body = await response.read()
157
+ except self.retryable_errors:
158
+ # The status is authoritative even when its optional
159
+ # diagnostic body is interrupted.
160
+ body = b""
161
+ # auto_decompress=False applies to the payload only:
162
+ # the error body must still be decoded, or the parsed
163
+ # server message is lost
164
+ encoding = (response.headers.get("Content-Encoding") or "").lower()
165
+ if not auto_decompress and encoding == "gzip":
166
+ with suppress(gzip.BadGzipFile, OSError):
167
+ body = gzip.decompress(body)
168
+ raise error_for_response(
169
+ ResponseData(
170
+ status=response.status,
171
+ headers={k.lower(): v for k, v in response.headers.items()},
172
+ body=body,
173
+ )
174
+ )
175
+ async for chunk in response.content.iter_chunked(CHUNK_SIZE):
176
+ yield chunk
177
+ except self.nonretryable_errors as exc:
178
+ raise ContreeAiohttpTimeoutError.wrap(exc) from exc
179
+ except aiohttp.ClientPayloadError as exc:
180
+ raise ContreeAiohttpStreamError.wrap(exc) from exc
181
+ except self.retryable_errors as exc:
182
+ raise ContreeAiohttpConnectionError.wrap(exc) from exc
183
+
184
+ async def close(self) -> None:
185
+ if (
186
+ self.__owns_session
187
+ and self._session is not None
188
+ and not self._session.closed
189
+ ):
190
+ await self._session.close()
@@ -16,9 +16,9 @@ import platform
16
16
  import sys
17
17
  import time
18
18
  from abc import ABC, abstractmethod
19
- from collections.abc import AsyncGenerator, Iterable, Iterator
19
+ from collections.abc import AsyncGenerator, Iterable, Iterator, Sequence
20
20
  from contextlib import aclosing
21
- from datetime import datetime
21
+ from datetime import datetime, timezone
22
22
  from pathlib import Path
23
23
  from types import EllipsisType, TracebackType
24
24
  from typing import IO, Any, Literal, TypeVar
@@ -35,6 +35,7 @@ from .exceptions import (
35
35
  from .models import (
36
36
  ClosableStreamRepr,
37
37
  DirectoryList,
38
+ EventDataCompletion,
38
39
  File,
39
40
  FileResponse,
40
41
  FilesListResponse,
@@ -55,6 +56,7 @@ from .models import (
55
56
  )
56
57
  from .profiles import AUTH_TYPE_IAM, Profile, ProfileError, resolve_profile
57
58
  from .runtime import (
59
+ EVENTS_UNAVAILABLE_STATUSES,
58
60
  TIGHT_LOOP_FLOOR,
59
61
  BodyFormatter,
60
62
  HeaderFormatter,
@@ -370,10 +372,13 @@ class ContreeSyncClient(ContreeClientBase, ABC):
370
372
  (410/425/5xx) reconnect from the last received event id, so no
371
373
  event is delivered twice; before every reconnect the operation
372
374
  status is probed and iteration ends if it already became
373
- terminal. Other API errors (404, 403, ...) propagate.
375
+ terminal. A status meaning the events endpoint itself doesn't
376
+ exist for this operation/server (400/404/405/406) stops
377
+ reconnecting and instead polls :meth:`get_operation_status`
378
+ until the operation is terminal. Other API errors propagate.
374
379
  Iteration also ends after the ``completion`` event. *timeout*
375
- bounds the whole wait; it is enforced between events and
376
- reconnect cycles and raises :class:`TimeoutError`.
380
+ bounds the whole wait; it is enforced between events, reconnect
381
+ cycles and polling, and raises :class:`TimeoutError`.
377
382
  """
378
383
  last_id = last_event_id
379
384
  delays = retry_generator()
@@ -410,6 +415,55 @@ class ContreeSyncClient(ContreeClientBase, ABC):
410
415
  last_id = exc.last_event_id
411
416
  self.log.warning("stream error (last_id=%s): %s", last_id, exc)
412
417
  except ContreeAPIError as exc:
418
+ if exc.status in EVENTS_UNAVAILABLE_STATUSES:
419
+ # the endpoint is gone, not just failing: reconnecting
420
+ # the same request will never work, but the operation
421
+ # may still finish - stop touching /events and poll
422
+ # status instead for the rest of this wait
423
+ self.log.warning(
424
+ "events endpoint unavailable (%d), falling back to polling",
425
+ exc.status,
426
+ )
427
+ while True:
428
+ check_deadline()
429
+ try:
430
+ response = self.get_operation_status(operation_id)
431
+ except (ContreeError, *self.retryable_errors):
432
+ response = None
433
+ if (
434
+ response is not None
435
+ and not isinstance(response.status, EllipsisType)
436
+ and response.status.is_terminal()
437
+ ):
438
+ # no event log to relay, but a caller of
439
+ # follow_operation_events must still observe a
440
+ # terminal completion rather than nothing at all
441
+ last_id = 0 if last_id is None else last_id + 1
442
+ duration = response.duration
443
+ image_size = response.image_size
444
+ yield OperationEvent(
445
+ id=last_id,
446
+ ts=datetime.now(timezone.utc),
447
+ type="completion",
448
+ data=EventDataCompletion(
449
+ status=response.status,
450
+ duration_ms=round(duration * 1000)
451
+ if isinstance(duration, (int, float))
452
+ else 0,
453
+ result_image_uuid=response.result_image_uuid,
454
+ error=response.error,
455
+ image_size_bytes=(
456
+ image_size
457
+ if isinstance(image_size, int)
458
+ else ...
459
+ ),
460
+ ),
461
+ )
462
+ return
463
+ delay = next(delays)
464
+ if deadline is not None:
465
+ delay = min(delay, max(0.0, deadline - time.monotonic()))
466
+ time.sleep(delay)
413
467
  retryable = exc.status in (410, 425) or 500 <= exc.status < 600
414
468
  if not retryable:
415
469
  raise
@@ -925,9 +979,12 @@ class ContreeSyncClient(ContreeClientBase, ABC):
925
979
  uid: User ID to run the process as. Example: ``0``.
926
980
  gid: Group ID to run the process as. Example: ``0``.
927
981
  timeout: Maximum execution time in seconds Example: ``60``.
928
- truncate_output_at: Maximum number of bytes to keep from
929
- stdout/stderr. Default is ``1048576`` (1 MiB). The maximum
930
- value is ``10485760`` (10 MiB). Example: ``65535``.
982
+ truncate_output_at: Maximum number of bytes to keep per stream
983
+ (stdout, stderr, and stdin) in the subprocess event log.
984
+ Does not affect stdin delivery to the child, which always
985
+ receives the complete stream regardless of this cap. Default
986
+ is ``1048576`` (1 MiB). The maximum value is ``10485760``
987
+ (10 MiB). Example: ``65535``.
931
988
  files: Map of absolute file paths to file metadata Example:
932
989
  ``{"/root/hello.txt":{"uuid":"a9165a5d-5c86-4bd8-8ee4-ae46c...``.
933
990
  """
@@ -1335,9 +1392,11 @@ class ContreeSyncClient(ContreeClientBase, ABC):
1335
1392
  main process and exec'd subprocesses. With ``close=false``
1336
1393
  the pipe stays open and more data can be written via
1337
1394
  ``POST /operations/{operationId}/subprocesses/{spid}/stdin``.
1338
- truncate_output_at: Maximum number of bytes of stdout / stderr
1339
- to keep. Defaults to 1 MiB; capped at 10 MiB. Example:
1340
- ``65535``.
1395
+ truncate_output_at: Maximum number of bytes to keep per stream
1396
+ (stdout, stderr, and stdin) in the subprocess event log.
1397
+ Does not affect delivery to the child — stdin is always
1398
+ delivered in full regardless of this cap. Defaults to 1 MiB;
1399
+ capped at 10 MiB. Example: ``65535``.
1341
1400
  """
1342
1401
  spec = operations.build_operation_subprocess_create(
1343
1402
  operation_id=operation_id,
@@ -1618,10 +1677,10 @@ class ContreeSyncClient(ContreeClientBase, ABC):
1618
1677
  def inspect_image_grep(
1619
1678
  self,
1620
1679
  image_uuid: str,
1621
- pattern: str,
1680
+ pattern: str | Sequence[str],
1622
1681
  *,
1623
- path: str | None = None,
1624
- glob: str | None = None,
1682
+ path: str | Sequence[str] | None = None,
1683
+ glob: str | Sequence[str] | None = None,
1625
1684
  max_count: int | None = None,
1626
1685
  max_total: int | None = None,
1627
1686
  case: Literal["sensitive", "insensitive", "smart"] | None = None,
@@ -1863,10 +1922,13 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
1863
1922
  (410/425/5xx) reconnect from the last received event id, so no
1864
1923
  event is delivered twice; before every reconnect the operation
1865
1924
  status is probed and iteration ends if it already became
1866
- terminal. Other API errors (404, 403, ...) propagate.
1925
+ terminal. A status meaning the events endpoint itself doesn't
1926
+ exist for this operation/server (400/404/405/406) stops
1927
+ reconnecting and instead polls :meth:`get_operation_status`
1928
+ until the operation is terminal. Other API errors propagate.
1867
1929
  Iteration also ends after the ``completion`` event. *timeout*
1868
- bounds the whole wait; it is enforced between events and
1869
- reconnect cycles and raises :class:`TimeoutError`.
1930
+ bounds the whole wait; it is enforced between events, reconnect
1931
+ cycles and polling, and raises :class:`TimeoutError`.
1870
1932
  """
1871
1933
  last_id = last_event_id
1872
1934
  delays = retry_generator()
@@ -1894,6 +1956,24 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
1894
1956
  check_deadline()
1895
1957
  raise
1896
1958
 
1959
+ async def operation_status_before_deadline() -> OperationResponse | None:
1960
+ try:
1961
+ if deadline is None:
1962
+ return await self.get_operation_status(operation_id)
1963
+ remaining = deadline - time.monotonic()
1964
+ if remaining <= 0:
1965
+ check_deadline()
1966
+ try:
1967
+ return await asyncio.wait_for(
1968
+ self.get_operation_status(operation_id),
1969
+ timeout=remaining,
1970
+ )
1971
+ except asyncio.TimeoutError:
1972
+ check_deadline()
1973
+ raise
1974
+ except (ContreeError, *self.retryable_errors):
1975
+ return None
1976
+
1897
1977
  while True:
1898
1978
  check_deadline()
1899
1979
  events_before = last_id
@@ -1923,6 +2003,51 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
1923
2003
  last_id = exc.last_event_id
1924
2004
  self.log.warning("stream error (last_id=%s): %s", last_id, exc)
1925
2005
  except ContreeAPIError as exc:
2006
+ if exc.status in EVENTS_UNAVAILABLE_STATUSES:
2007
+ # the endpoint is gone, not just failing: reconnecting
2008
+ # the same request will never work, but the operation
2009
+ # may still finish - stop touching /events and poll
2010
+ # status instead for the rest of this wait
2011
+ self.log.warning(
2012
+ "events endpoint unavailable (%d), falling back to polling",
2013
+ exc.status,
2014
+ )
2015
+ while True:
2016
+ response = await operation_status_before_deadline()
2017
+ if (
2018
+ response is not None
2019
+ and not isinstance(response.status, EllipsisType)
2020
+ and response.status.is_terminal()
2021
+ ):
2022
+ # no event log to relay, but a caller of
2023
+ # follow_operation_events must still observe a
2024
+ # terminal completion rather than nothing at all
2025
+ last_id = 0 if last_id is None else last_id + 1
2026
+ duration = response.duration
2027
+ image_size = response.image_size
2028
+ yield OperationEvent(
2029
+ id=last_id,
2030
+ ts=datetime.now(timezone.utc),
2031
+ type="completion",
2032
+ data=EventDataCompletion(
2033
+ status=response.status,
2034
+ duration_ms=round(duration * 1000)
2035
+ if isinstance(duration, (int, float))
2036
+ else 0,
2037
+ result_image_uuid=response.result_image_uuid,
2038
+ error=response.error,
2039
+ image_size_bytes=(
2040
+ image_size
2041
+ if isinstance(image_size, int)
2042
+ else ...
2043
+ ),
2044
+ ),
2045
+ )
2046
+ return
2047
+ delay = next(delays)
2048
+ if deadline is not None:
2049
+ delay = min(delay, max(0.0, deadline - time.monotonic()))
2050
+ await asyncio.sleep(delay)
1926
2051
  retryable = exc.status in (410, 425) or 500 <= exc.status < 600
1927
2052
  if not retryable:
1928
2053
  raise
@@ -2450,9 +2575,12 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
2450
2575
  uid: User ID to run the process as. Example: ``0``.
2451
2576
  gid: Group ID to run the process as. Example: ``0``.
2452
2577
  timeout: Maximum execution time in seconds Example: ``60``.
2453
- truncate_output_at: Maximum number of bytes to keep from
2454
- stdout/stderr. Default is ``1048576`` (1 MiB). The maximum
2455
- value is ``10485760`` (10 MiB). Example: ``65535``.
2578
+ truncate_output_at: Maximum number of bytes to keep per stream
2579
+ (stdout, stderr, and stdin) in the subprocess event log.
2580
+ Does not affect stdin delivery to the child, which always
2581
+ receives the complete stream regardless of this cap. Default
2582
+ is ``1048576`` (1 MiB). The maximum value is ``10485760``
2583
+ (10 MiB). Example: ``65535``.
2456
2584
  files: Map of absolute file paths to file metadata Example:
2457
2585
  ``{"/root/hello.txt":{"uuid":"a9165a5d-5c86-4bd8-8ee4-ae46c...``.
2458
2586
  """
@@ -2861,9 +2989,11 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
2861
2989
  main process and exec'd subprocesses. With ``close=false``
2862
2990
  the pipe stays open and more data can be written via
2863
2991
  ``POST /operations/{operationId}/subprocesses/{spid}/stdin``.
2864
- truncate_output_at: Maximum number of bytes of stdout / stderr
2865
- to keep. Defaults to 1 MiB; capped at 10 MiB. Example:
2866
- ``65535``.
2992
+ truncate_output_at: Maximum number of bytes to keep per stream
2993
+ (stdout, stderr, and stdin) in the subprocess event log.
2994
+ Does not affect delivery to the child — stdin is always
2995
+ delivered in full regardless of this cap. Defaults to 1 MiB;
2996
+ capped at 10 MiB. Example: ``65535``.
2867
2997
  """
2868
2998
  spec = operations.build_operation_subprocess_create(
2869
2999
  operation_id=operation_id,
@@ -3149,10 +3279,10 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
3149
3279
  async def inspect_image_grep(
3150
3280
  self,
3151
3281
  image_uuid: str,
3152
- pattern: str,
3282
+ pattern: str | Sequence[str],
3153
3283
  *,
3154
- path: str | None = None,
3155
- glob: str | None = None,
3284
+ path: str | Sequence[str] | None = None,
3285
+ glob: str | Sequence[str] | None = None,
3156
3286
  max_count: int | None = None,
3157
3287
  max_total: int | None = None,
3158
3288
  case: Literal["sensitive", "insensitive", "smart"] | None = None,
@@ -9,11 +9,36 @@ class ContreeError(Exception):
9
9
  """Base class for all contree-client errors."""
10
10
 
11
11
 
12
- class DecompressionError(ContreeError):
12
+ class ContreeTransportError(ContreeError):
13
+ """Wire-level error base; each backend's subclass also inherits its
14
+ matching native exception type. Construct via :meth:`wrap`."""
15
+
16
+ @classmethod
17
+ def wrap(cls, original: BaseException) -> BaseException:
18
+ """Build an instance from *original*; return it unwrapped on failure."""
19
+ try:
20
+ return cls(*original.args)
21
+ except Exception:
22
+ return original
23
+
24
+
25
+ class ContreeConnectionError(ContreeTransportError):
26
+ """Failed to establish or maintain the connection."""
27
+
28
+
29
+ class ContreeTimeoutError(ContreeTransportError):
30
+ """A connect, read or overall deadline elapsed."""
31
+
32
+
33
+ class ContreeStreamError(ContreeTransportError):
34
+ """The response body arrived but could not be consumed correctly."""
35
+
36
+
37
+ class DecompressionError(ContreeStreamError):
13
38
  """The compressed response body ended prematurely or is corrupt."""
14
39
 
15
40
 
16
- class SSEStreamError(ContreeError):
41
+ class SSEStreamError(ContreeStreamError):
17
42
  """The server terminated an SSE stream with an in-band error frame.
18
43
 
19
44
  Per the API convention this is the in-band equivalent of a 410:
@@ -31,7 +56,11 @@ class SSEStreamError(ContreeError):
31
56
  self.last_event_id = last_event_id
32
57
 
33
58
 
34
- class ContreeAPIError(ContreeError):
59
+ class ContreeHTTPError(ContreeTransportError):
60
+ """A full HTTP response with a status line was received."""
61
+
62
+
63
+ class ContreeAPIError(ContreeHTTPError):
35
64
  """An HTTP error response returned by the Contree API."""
36
65
 
37
66
  def __init__(
@@ -25,6 +25,7 @@ from collections.abc import Callable, Iterator
25
25
  from urllib.parse import urlsplit
26
26
 
27
27
  from . import base
28
+ from .exceptions import ContreeConnectionError, ContreeTimeoutError
28
29
  from .runtime import (
29
30
  CHUNK_SIZE,
30
31
  RequestSpec,
@@ -38,6 +39,15 @@ from .runtime import (
38
39
  from .spec_info import DEFAULT_BASE_URL
39
40
  from .types import logger
40
41
 
42
+
43
+ class ContreeHttpConnectionError(ContreeConnectionError, OSError):
44
+ """A `ContreeConnectionError` that is also an `OSError`."""
45
+
46
+
47
+ class ContreeHttpTimeoutError(ContreeTimeoutError, TimeoutError):
48
+ """A `ContreeTimeoutError` that is also a stdlib `TimeoutError`."""
49
+
50
+
41
51
  # the reused keepalive connection may have been closed by the server
42
52
  # while it sat in the pool; these surface exactly that and warrant one
43
53
  # resend on a fresh connection (RemoteDisconnected is a BadStatusLine)
@@ -271,11 +281,19 @@ class ContreeClient(base.ContreeSyncClient):
271
281
  self.log.debug("stale pooled connection, resending: %s", exc)
272
282
  rewind_body(spec, start)
273
283
  continue
284
+ if isinstance(exc, self.nonretryable_errors):
285
+ raise ContreeHttpTimeoutError.wrap(exc) from exc
286
+ if isinstance(exc, self.retryable_errors):
287
+ raise ContreeHttpConnectionError.wrap(exc) from exc
274
288
  raise
275
289
  try:
276
290
  data = read_response(response)
277
- except BaseException:
291
+ except BaseException as exc:
278
292
  self._pool.discard(connection)
293
+ if isinstance(exc, self.nonretryable_errors):
294
+ raise ContreeHttpTimeoutError.wrap(exc) from exc
295
+ if isinstance(exc, self.retryable_errors):
296
+ raise ContreeHttpConnectionError.wrap(exc) from exc
279
297
  raise
280
298
  # the body is fully drained: the connection is reusable unless
281
299
  # the server asked to close it (`Connection: close` sets
@@ -295,8 +313,12 @@ class ContreeClient(base.ContreeSyncClient):
295
313
  connection = self._connect()
296
314
  try:
297
315
  response = self._send_on(connection, spec)
298
- except BaseException:
316
+ except BaseException as exc:
299
317
  connection.close()
318
+ if isinstance(exc, self.nonretryable_errors):
319
+ raise ContreeHttpTimeoutError.wrap(exc) from exc
320
+ if isinstance(exc, self.retryable_errors):
321
+ raise ContreeHttpConnectionError.wrap(exc) from exc
300
322
  raise
301
323
  try:
302
324
  self.log.debug(
@@ -326,6 +348,12 @@ class ContreeClient(base.ContreeSyncClient):
326
348
  chunk = decoder.decompress(raw)
327
349
  if chunk:
328
350
  yield chunk
351
+ except BaseException as exc:
352
+ if isinstance(exc, self.nonretryable_errors):
353
+ raise ContreeHttpTimeoutError.wrap(exc) from exc
354
+ if isinstance(exc, self.retryable_errors):
355
+ raise ContreeHttpConnectionError.wrap(exc) from exc
356
+ raise
329
357
  finally:
330
358
  connection.close()
331
359