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.
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/PKG-INFO +1 -1
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/__init__.py +10 -0
- contree_client-0.3.0/contree_client/aiohttp.py +190 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/base.py +156 -26
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/exceptions.py +32 -3
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/http.py +30 -2
- contree_client-0.3.0/contree_client/httpx.py +281 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/models.py +19 -8
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/operations.py +5 -4
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/requests.py +71 -25
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/runtime.py +13 -4
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/spec_info.py +15 -6
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/urllib3.py +66 -29
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/pyproject.toml +1 -1
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/pyproject.toml.orig +1 -1
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/stub_server.py +45 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_keepalive.py +5 -1
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_operation_helpers.py +49 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_runtime.py +14 -0
- contree_client-0.3.0/tests/test_transport_error_hierarchy.py +218 -0
- contree_client-0.2.1.dev0/contree_client/aiohttp.py +0 -159
- contree_client-0.2.1.dev0/contree_client/httpx.py +0 -230
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/LICENSE +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/README.md +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/asyncio.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/profiles.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/py.typed +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/sync.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/testing.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/contree_client/types.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/conftest.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_aiohttp_payload_interruption.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_autodetect.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_clients.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_file_helpers.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_integration.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_interface.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_logging.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_models.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_operations.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_pagination.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_profiles.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_retry.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_sse.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_stream_lifecycle.py +0 -0
- {contree_client-0.2.1.dev0 → contree_client-0.3.0}/tests/test_testing.py +0 -0
|
@@ -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.
|
|
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
|
|
376
|
-
|
|
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
|
|
929
|
-
stdout
|
|
930
|
-
|
|
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
|
|
1339
|
-
|
|
1340
|
-
|
|
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.
|
|
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
|
|
1869
|
-
|
|
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
|
|
2454
|
-
stdout
|
|
2455
|
-
|
|
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
|
|
2865
|
-
|
|
2866
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|