contree-client 0.2.0__tar.gz → 0.2.1.dev0__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.0 → contree_client-0.2.1.dev0}/PKG-INFO +1 -1
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/aiohttp.py +7 -1
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/base.py +92 -10
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/models.py +26 -3
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/operations.py +6 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/spec_info.py +46 -3
- contree_client-0.2.1.dev0/pyproject.toml +78 -0
- contree_client-0.2.0/pyproject.toml → contree_client-0.2.1.dev0/pyproject.toml.orig +1 -1
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/stub_server.py +115 -4
- contree_client-0.2.1.dev0/tests/test_aiohttp_payload_interruption.py +234 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/LICENSE +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/README.md +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/__init__.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/asyncio.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/exceptions.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/http.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/httpx.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/profiles.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/py.typed +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/requests.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/runtime.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/sync.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/testing.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/types.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/urllib3.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/conftest.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_autodetect.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_clients.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_file_helpers.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_integration.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_interface.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_keepalive.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_logging.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_models.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_operation_helpers.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_operations.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_pagination.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_profiles.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_retry.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_runtime.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_sse.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_stream_lifecycle.py +0 -0
- {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_testing.py +0 -0
|
@@ -35,6 +35,7 @@ class ContreeAsyncClient(base.ContreeAsyncClient):
|
|
|
35
35
|
retryable_errors = (
|
|
36
36
|
aiohttp.ClientConnectionError,
|
|
37
37
|
aiohttp.ServerConnectionError,
|
|
38
|
+
aiohttp.ClientPayloadError,
|
|
38
39
|
)
|
|
39
40
|
nonretryable_errors = (TimeoutError, asyncio.TimeoutError)
|
|
40
41
|
|
|
@@ -126,7 +127,12 @@ class ContreeAsyncClient(base.ContreeAsyncClient):
|
|
|
126
127
|
) as response:
|
|
127
128
|
self.log.debug("%s %s -> %d (stream)", spec.method, url, response.status)
|
|
128
129
|
if not 200 <= response.status < 300:
|
|
129
|
-
|
|
130
|
+
try:
|
|
131
|
+
body = await response.read()
|
|
132
|
+
except self.retryable_errors:
|
|
133
|
+
# The status is authoritative even when its optional
|
|
134
|
+
# diagnostic body is interrupted.
|
|
135
|
+
body = b""
|
|
130
136
|
# auto_decompress=False applies to the payload only:
|
|
131
137
|
# the error body must still be decoded, or the parsed
|
|
132
138
|
# server message is lost
|
|
@@ -1625,6 +1625,8 @@ class ContreeSyncClient(ContreeClientBase, ABC):
|
|
|
1625
1625
|
max_count: int | None = None,
|
|
1626
1626
|
max_total: int | None = None,
|
|
1627
1627
|
case: Literal["sensitive", "insensitive", "smart"] | None = None,
|
|
1628
|
+
before: int | None = None,
|
|
1629
|
+
after: int | None = None,
|
|
1628
1630
|
) -> GrepResult:
|
|
1629
1631
|
"""Search file contents in image.
|
|
1630
1632
|
|
|
@@ -1637,20 +1639,39 @@ class ContreeSyncClient(ContreeClientBase, ABC):
|
|
|
1637
1639
|
(containing a NUL byte) are skipped. Rust regex syntax, linear-time
|
|
1638
1640
|
matching.
|
|
1639
1641
|
|
|
1642
|
+
Results are a flat, file-ordered list of lines. ``before``/``after``
|
|
1643
|
+
request extra
|
|
1644
|
+
``type: context`` lines around each ``type: match`` line, mirroring
|
|
1645
|
+
ripgrep's
|
|
1646
|
+
--json message type field.
|
|
1647
|
+
|
|
1648
|
+
``path`` may be repeated to search multiple roots in one call, the same
|
|
1649
|
+
way
|
|
1650
|
+
``pattern``/``glob`` already can; results are concatenated in the given
|
|
1651
|
+
order
|
|
1652
|
+
and share the ``max_total`` budget across all of them.
|
|
1653
|
+
|
|
1640
1654
|
Args:
|
|
1641
1655
|
image_uuid: The UUID of the image to inspect Example:
|
|
1642
1656
|
``12345678-9abc-baba-deda-0123456789ab``.
|
|
1643
1657
|
pattern: Regex pattern to search for. May be repeated (max 16);
|
|
1644
1658
|
patterns are OR-ed. Max 1024 characters each. Example:
|
|
1645
1659
|
``^PermitRootLogin``.
|
|
1646
|
-
path: File or directory to search inside the image.
|
|
1647
|
-
|
|
1660
|
+
path: File or directory to search inside the image. May be
|
|
1661
|
+
repeated (max 16) to search multiple roots; results are
|
|
1662
|
+
concatenated in the given order, sharing max_total across
|
|
1663
|
+
all paths. Defaults to the rootfs root. Example: ``/etc``.
|
|
1648
1664
|
glob: Glob to filter files (ripgrep -g semantics, "!" negates).
|
|
1649
1665
|
May be repeated. Example: ``*.conf``.
|
|
1650
1666
|
max_count: Maximum matches per file (hard cap 10000).
|
|
1651
1667
|
max_total: Maximum total matches across all files (default 1000,
|
|
1652
1668
|
hard cap 10000).
|
|
1653
1669
|
case: Case sensitivity mode.
|
|
1670
|
+
before: Lines of context to include before each match (ripgrep
|
|
1671
|
+
-B semantics, hard cap 50). For symmetric context (grep -C
|
|
1672
|
+
n), pass both before and after yourself.
|
|
1673
|
+
after: Lines of context to include after each match (ripgrep -A
|
|
1674
|
+
semantics, hard cap 50).
|
|
1654
1675
|
"""
|
|
1655
1676
|
spec = operations.build_inspect_image_grep(
|
|
1656
1677
|
image_uuid=image_uuid,
|
|
@@ -1660,6 +1681,8 @@ class ContreeSyncClient(ContreeClientBase, ABC):
|
|
|
1660
1681
|
max_count=max_count,
|
|
1661
1682
|
max_total=max_total,
|
|
1662
1683
|
case=case,
|
|
1684
|
+
before=before,
|
|
1685
|
+
after=after,
|
|
1663
1686
|
)
|
|
1664
1687
|
self.log_request(spec)
|
|
1665
1688
|
response = self.call(spec)
|
|
@@ -1803,9 +1826,26 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
1803
1826
|
``OperationResponse``. Raises :class:`TimeoutError` when
|
|
1804
1827
|
*timeout* seconds elapse first.
|
|
1805
1828
|
"""
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1829
|
+
|
|
1830
|
+
async def wait_and_fetch() -> OperationResponse:
|
|
1831
|
+
async for event in self.follow_operation_events(operation_id):
|
|
1832
|
+
self.log.debug("wait_operation: event %s %s", event.id, event.type)
|
|
1833
|
+
return await self.get_operation_status(operation_id)
|
|
1834
|
+
|
|
1835
|
+
if timeout is None:
|
|
1836
|
+
return await wait_and_fetch()
|
|
1837
|
+
task = asyncio.create_task(wait_and_fetch())
|
|
1838
|
+
try:
|
|
1839
|
+
done, _pending = await asyncio.wait((task,), timeout=timeout)
|
|
1840
|
+
if task in done:
|
|
1841
|
+
return task.result()
|
|
1842
|
+
finally:
|
|
1843
|
+
if not task.done():
|
|
1844
|
+
task.cancel()
|
|
1845
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
1846
|
+
raise TimeoutError(
|
|
1847
|
+
f"operation {operation_id} did not complete within {timeout}s"
|
|
1848
|
+
)
|
|
1809
1849
|
|
|
1810
1850
|
async def follow_operation_events(
|
|
1811
1851
|
self,
|
|
@@ -1839,6 +1879,21 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
1839
1879
|
f" within {timeout}s"
|
|
1840
1880
|
)
|
|
1841
1881
|
|
|
1882
|
+
async def operation_terminal_before_deadline() -> bool:
|
|
1883
|
+
if deadline is None:
|
|
1884
|
+
return await self.operation_terminal(operation_id)
|
|
1885
|
+
remaining = deadline - time.monotonic()
|
|
1886
|
+
if remaining <= 0:
|
|
1887
|
+
check_deadline()
|
|
1888
|
+
try:
|
|
1889
|
+
return await asyncio.wait_for(
|
|
1890
|
+
self.operation_terminal(operation_id),
|
|
1891
|
+
timeout=remaining,
|
|
1892
|
+
)
|
|
1893
|
+
except asyncio.TimeoutError:
|
|
1894
|
+
check_deadline()
|
|
1895
|
+
raise
|
|
1896
|
+
|
|
1842
1897
|
while True:
|
|
1843
1898
|
check_deadline()
|
|
1844
1899
|
events_before = last_id
|
|
@@ -1871,7 +1926,7 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
1871
1926
|
retryable = exc.status in (410, 425) or 500 <= exc.status < 600
|
|
1872
1927
|
if not retryable:
|
|
1873
1928
|
raise
|
|
1874
|
-
if await
|
|
1929
|
+
if await operation_terminal_before_deadline():
|
|
1875
1930
|
return
|
|
1876
1931
|
self.log.warning("stream connect failed (%d): %s", exc.status, exc)
|
|
1877
1932
|
retry_after = exc.retry_after
|
|
@@ -1883,14 +1938,18 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
1883
1938
|
continue
|
|
1884
1939
|
except self.retryable_errors as exc:
|
|
1885
1940
|
if isinstance(exc, self.nonretryable_errors):
|
|
1941
|
+
check_deadline()
|
|
1886
1942
|
raise
|
|
1887
1943
|
self.log.warning("stream broken (last_id=%s): %s", last_id, exc)
|
|
1888
1944
|
# the stream ended or broke without a completion frame:
|
|
1889
1945
|
# the retry must not outlive the operation itself
|
|
1890
|
-
if await
|
|
1946
|
+
if await operation_terminal_before_deadline():
|
|
1891
1947
|
return
|
|
1892
1948
|
if last_id == events_before:
|
|
1893
|
-
|
|
1949
|
+
delay = TIGHT_LOOP_FLOOR
|
|
1950
|
+
if deadline is not None:
|
|
1951
|
+
delay = min(delay, max(0.0, deadline - time.monotonic()))
|
|
1952
|
+
await asyncio.sleep(delay)
|
|
1894
1953
|
|
|
1895
1954
|
async def resolve_image(self, ref: str) -> str:
|
|
1896
1955
|
"""Resolve an image reference to a UUID.
|
|
@@ -3097,6 +3156,8 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
3097
3156
|
max_count: int | None = None,
|
|
3098
3157
|
max_total: int | None = None,
|
|
3099
3158
|
case: Literal["sensitive", "insensitive", "smart"] | None = None,
|
|
3159
|
+
before: int | None = None,
|
|
3160
|
+
after: int | None = None,
|
|
3100
3161
|
) -> GrepResult:
|
|
3101
3162
|
"""Search file contents in image.
|
|
3102
3163
|
|
|
@@ -3109,20 +3170,39 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
3109
3170
|
(containing a NUL byte) are skipped. Rust regex syntax, linear-time
|
|
3110
3171
|
matching.
|
|
3111
3172
|
|
|
3173
|
+
Results are a flat, file-ordered list of lines. ``before``/``after``
|
|
3174
|
+
request extra
|
|
3175
|
+
``type: context`` lines around each ``type: match`` line, mirroring
|
|
3176
|
+
ripgrep's
|
|
3177
|
+
--json message type field.
|
|
3178
|
+
|
|
3179
|
+
``path`` may be repeated to search multiple roots in one call, the same
|
|
3180
|
+
way
|
|
3181
|
+
``pattern``/``glob`` already can; results are concatenated in the given
|
|
3182
|
+
order
|
|
3183
|
+
and share the ``max_total`` budget across all of them.
|
|
3184
|
+
|
|
3112
3185
|
Args:
|
|
3113
3186
|
image_uuid: The UUID of the image to inspect Example:
|
|
3114
3187
|
``12345678-9abc-baba-deda-0123456789ab``.
|
|
3115
3188
|
pattern: Regex pattern to search for. May be repeated (max 16);
|
|
3116
3189
|
patterns are OR-ed. Max 1024 characters each. Example:
|
|
3117
3190
|
``^PermitRootLogin``.
|
|
3118
|
-
path: File or directory to search inside the image.
|
|
3119
|
-
|
|
3191
|
+
path: File or directory to search inside the image. May be
|
|
3192
|
+
repeated (max 16) to search multiple roots; results are
|
|
3193
|
+
concatenated in the given order, sharing max_total across
|
|
3194
|
+
all paths. Defaults to the rootfs root. Example: ``/etc``.
|
|
3120
3195
|
glob: Glob to filter files (ripgrep -g semantics, "!" negates).
|
|
3121
3196
|
May be repeated. Example: ``*.conf``.
|
|
3122
3197
|
max_count: Maximum matches per file (hard cap 10000).
|
|
3123
3198
|
max_total: Maximum total matches across all files (default 1000,
|
|
3124
3199
|
hard cap 10000).
|
|
3125
3200
|
case: Case sensitivity mode.
|
|
3201
|
+
before: Lines of context to include before each match (ripgrep
|
|
3202
|
+
-B semantics, hard cap 50). For symmetric context (grep -C
|
|
3203
|
+
n), pass both before and after yourself.
|
|
3204
|
+
after: Lines of context to include after each match (ripgrep -A
|
|
3205
|
+
semantics, hard cap 50).
|
|
3126
3206
|
"""
|
|
3127
3207
|
spec = operations.build_inspect_image_grep(
|
|
3128
3208
|
image_uuid=image_uuid,
|
|
@@ -3132,6 +3212,8 @@ class ContreeAsyncClient(ContreeClientBase, ABC):
|
|
|
3132
3212
|
max_count=max_count,
|
|
3133
3213
|
max_total=max_total,
|
|
3134
3214
|
case=case,
|
|
3215
|
+
before=before,
|
|
3216
|
+
after=after,
|
|
3135
3217
|
)
|
|
3136
3218
|
self.log_request(spec)
|
|
3137
3219
|
response = await self.call(spec)
|
|
@@ -285,7 +285,18 @@ class GrepMatch(ContreeModel):
|
|
|
285
285
|
"example": 8192,
|
|
286
286
|
}
|
|
287
287
|
)
|
|
288
|
-
submatches: list[GrepSubmatch]
|
|
288
|
+
submatches: list[GrepSubmatch] = field(
|
|
289
|
+
metadata={"description": "Empty for context lines (they carry no submatches)"}
|
|
290
|
+
)
|
|
291
|
+
type: Literal["match", "context"] = field(
|
|
292
|
+
metadata={
|
|
293
|
+
"description": (
|
|
294
|
+
'Mirrors ripgrep\'s --json message type: "match" is an actual match, '
|
|
295
|
+
'"context" is a\nbefore/after context line requested via before/after.'
|
|
296
|
+
),
|
|
297
|
+
"example": "match",
|
|
298
|
+
}
|
|
299
|
+
)
|
|
289
300
|
|
|
290
301
|
@classmethod
|
|
291
302
|
def parse_fields(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -296,6 +307,7 @@ class GrepMatch(ContreeModel):
|
|
|
296
307
|
"line_text": data["line_text"],
|
|
297
308
|
"line_bytes": data["line_bytes"],
|
|
298
309
|
"submatches": [GrepSubmatch.from_dict(item) for item in data["submatches"]],
|
|
310
|
+
"type": data["type"],
|
|
299
311
|
}
|
|
300
312
|
|
|
301
313
|
|
|
@@ -303,7 +315,11 @@ class GrepMatch(ContreeModel):
|
|
|
303
315
|
class GrepResult(ContreeModel):
|
|
304
316
|
path: str = field(
|
|
305
317
|
metadata={
|
|
306
|
-
"description":
|
|
318
|
+
"description": (
|
|
319
|
+
"The first path inside the image that was searched. When `path` was "
|
|
320
|
+
"repeated,\nsee each row's own `path` for which root it actually came "
|
|
321
|
+
"from."
|
|
322
|
+
),
|
|
307
323
|
"example": "/etc",
|
|
308
324
|
}
|
|
309
325
|
)
|
|
@@ -313,7 +329,14 @@ class GrepResult(ContreeModel):
|
|
|
313
329
|
"example": ["^PermitRootLogin"],
|
|
314
330
|
}
|
|
315
331
|
)
|
|
316
|
-
matches: list[GrepMatch]
|
|
332
|
+
matches: list[GrepMatch] = field(
|
|
333
|
+
metadata={
|
|
334
|
+
"description": (
|
|
335
|
+
"File-ordered lines; each is a match or (when before/after are set) "
|
|
336
|
+
"surrounding context"
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
)
|
|
317
340
|
truncated: bool = field(
|
|
318
341
|
metadata={
|
|
319
342
|
"description": (
|
|
@@ -598,6 +598,8 @@ def build_inspect_image_grep(
|
|
|
598
598
|
max_count: int | None = None,
|
|
599
599
|
max_total: int | None = None,
|
|
600
600
|
case: Literal["sensitive", "insensitive", "smart"] | None = None,
|
|
601
|
+
before: int | None = None,
|
|
602
|
+
after: int | None = None,
|
|
601
603
|
) -> RequestSpec:
|
|
602
604
|
"""Build the request for `GET /inspect/{image_uuid}/grep`."""
|
|
603
605
|
query: dict[str, str] = {}
|
|
@@ -612,6 +614,10 @@ def build_inspect_image_grep(
|
|
|
612
614
|
query["max_total"] = str(max_total)
|
|
613
615
|
if case is not None:
|
|
614
616
|
query["case"] = case
|
|
617
|
+
if before is not None:
|
|
618
|
+
query["before"] = str(before)
|
|
619
|
+
if after is not None:
|
|
620
|
+
query["after"] = str(after)
|
|
615
621
|
request_path = f"/inspect/{quote_path(image_uuid)}/grep"
|
|
616
622
|
return RequestSpec(method="GET", path=request_path, idempotent=True, query=query)
|
|
617
623
|
|
|
@@ -1571,6 +1571,14 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1571
1571
|
Symlinks are never followed during traversal, hidden files are searched,
|
|
1572
1572
|
`.gitignore`-style files inside the image are ignored, and binary files
|
|
1573
1573
|
(containing a NUL byte) are skipped. Rust regex syntax, linear-time matching.
|
|
1574
|
+
|
|
1575
|
+
Results are a flat, file-ordered list of lines. `before`/`after` request extra
|
|
1576
|
+
`type: context` lines around each `type: match` line, mirroring ripgrep's
|
|
1577
|
+
--json message type field.
|
|
1578
|
+
|
|
1579
|
+
`path` may be repeated to search multiple roots in one call, the same way
|
|
1580
|
+
`pattern`/`glob` already can; results are concatenated in the given order
|
|
1581
|
+
and share the `max_total` budget across all of them.
|
|
1574
1582
|
operationId: inspectImageGrep
|
|
1575
1583
|
tags:
|
|
1576
1584
|
- inspect
|
|
@@ -1593,7 +1601,10 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1593
1601
|
in: query
|
|
1594
1602
|
required: false
|
|
1595
1603
|
allowReserved: true
|
|
1596
|
-
description:
|
|
1604
|
+
description: |
|
|
1605
|
+
File or directory to search inside the image. May be repeated (max 16) to search
|
|
1606
|
+
multiple roots; results are concatenated in the given order, sharing max_total
|
|
1607
|
+
across all paths. Defaults to the rootfs root.
|
|
1597
1608
|
schema:
|
|
1598
1609
|
type: string
|
|
1599
1610
|
default: "/"
|
|
@@ -1631,6 +1642,26 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1631
1642
|
type: string
|
|
1632
1643
|
enum: [sensitive, insensitive, smart]
|
|
1633
1644
|
default: sensitive
|
|
1645
|
+
- name: before
|
|
1646
|
+
in: query
|
|
1647
|
+
required: false
|
|
1648
|
+
description: |
|
|
1649
|
+
Lines of context to include before each match (ripgrep -B semantics, hard cap 50).
|
|
1650
|
+
For symmetric context (grep -C n), pass both before and after yourself.
|
|
1651
|
+
schema:
|
|
1652
|
+
type: integer
|
|
1653
|
+
minimum: 0
|
|
1654
|
+
maximum: 50
|
|
1655
|
+
default: 0
|
|
1656
|
+
- name: after
|
|
1657
|
+
in: query
|
|
1658
|
+
required: false
|
|
1659
|
+
description: Lines of context to include after each match (ripgrep -A semantics, hard cap 50).
|
|
1660
|
+
schema:
|
|
1661
|
+
type: integer
|
|
1662
|
+
minimum: 0
|
|
1663
|
+
maximum: 50
|
|
1664
|
+
default: 0
|
|
1634
1665
|
responses:
|
|
1635
1666
|
'200':
|
|
1636
1667
|
description: OK
|
|
@@ -1928,6 +1959,7 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1928
1959
|
- line_text
|
|
1929
1960
|
- line_bytes
|
|
1930
1961
|
- submatches
|
|
1962
|
+
- type
|
|
1931
1963
|
properties:
|
|
1932
1964
|
path:
|
|
1933
1965
|
type: string
|
|
@@ -1951,8 +1983,16 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1951
1983
|
example: 8192
|
|
1952
1984
|
submatches:
|
|
1953
1985
|
type: array
|
|
1986
|
+
description: Empty for context lines (they carry no submatches)
|
|
1954
1987
|
items:
|
|
1955
1988
|
$ref: '#/components/schemas/GrepSubmatch'
|
|
1989
|
+
type:
|
|
1990
|
+
type: string
|
|
1991
|
+
enum: [match, context]
|
|
1992
|
+
description: |
|
|
1993
|
+
Mirrors ripgrep's --json message type: "match" is an actual match, "context" is a
|
|
1994
|
+
before/after context line requested via before/after.
|
|
1995
|
+
example: match
|
|
1956
1996
|
|
|
1957
1997
|
GrepResult:
|
|
1958
1998
|
type: object
|
|
@@ -1964,7 +2004,9 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1964
2004
|
properties:
|
|
1965
2005
|
path:
|
|
1966
2006
|
type: string
|
|
1967
|
-
description:
|
|
2007
|
+
description: |
|
|
2008
|
+
The first path inside the image that was searched. When `path` was repeated,
|
|
2009
|
+
see each row's own `path` for which root it actually came from.
|
|
1968
2010
|
example: "/etc"
|
|
1969
2011
|
patterns:
|
|
1970
2012
|
type: array
|
|
@@ -1974,6 +2016,7 @@ Generated by codegen from the OpenAPI spec - do not edit.
|
|
|
1974
2016
|
example: ["^PermitRootLogin"]
|
|
1975
2017
|
matches:
|
|
1976
2018
|
type: array
|
|
2019
|
+
description: File-ordered lines; each is a match or (when before/after are set) surrounding context
|
|
1977
2020
|
items:
|
|
1978
2021
|
$ref: '#/components/schemas/GrepMatch'
|
|
1979
2022
|
truncated:
|
|
@@ -3403,4 +3446,4 @@ DEFAULT_BASE_URL = "https://api.tokenfactory.nebius.com/sandboxes"
|
|
|
3403
3446
|
|
|
3404
3447
|
# sha256 of the exact OpenAPI document this package was built from -
|
|
3405
3448
|
# the build input provenance
|
|
3406
|
-
SPEC_SHA256 = "
|
|
3449
|
+
SPEC_SHA256 = "2bfd239341e6f61ccba1aafb39cef7b476d56f424156860a204f089d7d79b7bf"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.28,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contree-client"
|
|
7
|
+
version = "0.2.1.dev0"
|
|
8
|
+
description = "Python client for the Contree API, generated from the OpenAPI specification"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "Dmitry Orlov"
|
|
17
|
+
email = "mosquito@nebius.com"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://contree.dev/"
|
|
21
|
+
Documentation = "https://docs.tokenfactory.nebius.com/sandboxes/overview"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
urllib3 = ["urllib3>=2.0"]
|
|
25
|
+
requests = ["requests>=2.28"]
|
|
26
|
+
aiohttp = ["aiohttp>=3.9"]
|
|
27
|
+
httpx = ["httpx>=0.27"]
|
|
28
|
+
dev = [
|
|
29
|
+
"contree-client[urllib3,requests,aiohttp,httpx]",
|
|
30
|
+
"pytest>=8",
|
|
31
|
+
"pytest-cov>=7.1.0",
|
|
32
|
+
"pytest-timeout>=2.3",
|
|
33
|
+
"markdown-pytest[async]>=0.3",
|
|
34
|
+
"types-requests",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.uv.build-backend]
|
|
38
|
+
module-root = ""
|
|
39
|
+
namespace = true
|
|
40
|
+
source-include = ["tests/**"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
markers = ["integration: live tests against the real Contree API; auto-skip without an active contree-cli profile"]
|
|
45
|
+
|
|
46
|
+
[tool.ty.environment]
|
|
47
|
+
python-version = "3.10"
|
|
48
|
+
|
|
49
|
+
[tool.ty.src]
|
|
50
|
+
respect-ignore-files = false
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 88
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = [
|
|
58
|
+
"A",
|
|
59
|
+
"B",
|
|
60
|
+
"C4",
|
|
61
|
+
"E",
|
|
62
|
+
"F",
|
|
63
|
+
"FURB",
|
|
64
|
+
"I",
|
|
65
|
+
"ISC",
|
|
66
|
+
"PERF",
|
|
67
|
+
"PIE",
|
|
68
|
+
"PLC",
|
|
69
|
+
"PLE",
|
|
70
|
+
"RET",
|
|
71
|
+
"RSE",
|
|
72
|
+
"RUF",
|
|
73
|
+
"SIM",
|
|
74
|
+
"T20",
|
|
75
|
+
"UP",
|
|
76
|
+
"W",
|
|
77
|
+
]
|
|
78
|
+
ignore = ["ISC001"]
|
|
@@ -32,11 +32,17 @@ RETRY_AFTER_OPERATION_UUID = "00000000-0000-0000-0000-000000000425"
|
|
|
32
32
|
PENDING_OPERATION_UUID = "00000000-0000-0000-0000-0000000e4ec1"
|
|
33
33
|
EXECUTING_OPERATION_UUID = "00000000-0000-0000-0000-0000000e4ec2"
|
|
34
34
|
RECONNECT_OPERATION_UUID = "00000000-0000-0000-0000-00000000ec0e"
|
|
35
|
+
PAYLOAD_INTERRUPTION_OPERATION_UUID = "00000000-0000-0000-0000-00000000badd"
|
|
36
|
+
PAYLOAD_TIMEOUT_OPERATION_UUID = "00000000-0000-0000-0000-00000000dead"
|
|
37
|
+
PAYLOAD_STALLED_STATUS_OPERATION_UUID = "00000000-0000-0000-0000-0000000057a1"
|
|
38
|
+
PAYLOAD_FORBIDDEN_OPERATION_UUID = "00000000-0000-0000-0000-000000000403"
|
|
39
|
+
PAYLOAD_FINAL_STATUS_OPERATION_UUID = "00000000-0000-0000-0000-00000000f1a1"
|
|
35
40
|
CLOSING_OPERATION_UUID = "00000000-0000-0000-0000-0000000c105e"
|
|
36
41
|
DROPPING_OPERATION_UUID = "00000000-0000-0000-0000-00000000d40b"
|
|
37
42
|
KEEPALIVE_OPERATION_UUID = "00000000-0000-0000-0000-0000000cee9a"
|
|
38
43
|
RESET_OPERATION_UUID = "00000000-0000-0000-0000-000000000e5e"
|
|
39
44
|
SSE_HANG_SECONDS = 10.0
|
|
45
|
+
STATUS_HANG_SECONDS = 2.0
|
|
40
46
|
FILE_UUID = "a9165a5d-5c86-4bd8-8ee4-ae46c19cf45d"
|
|
41
47
|
KNOWN_SHA256 = "a" * 64
|
|
42
48
|
CREATED_AT = "2024-01-01T12:00:00+00:00"
|
|
@@ -285,6 +291,9 @@ class Reply:
|
|
|
285
291
|
hang: float = 0.0
|
|
286
292
|
# simulate a peer dying mid-transfer: the gzip trailer never comes
|
|
287
293
|
truncate_gzip: bool = False
|
|
294
|
+
# simulate invalid HTTP chunk framing: close without the terminal
|
|
295
|
+
# zero-length chunk after delivering all declared chunks
|
|
296
|
+
truncate_chunked: bool = False
|
|
288
297
|
# serve a normal keepalive response, then silently close the TCP
|
|
289
298
|
# connection - the client only finds out when it tries to reuse it
|
|
290
299
|
drop_after: bool = False
|
|
@@ -400,6 +409,106 @@ def route(request: Captured, attempts: collections.Counter[str]) -> Reply:
|
|
|
400
409
|
stream_chunks=chunks,
|
|
401
410
|
)
|
|
402
411
|
|
|
412
|
+
if (
|
|
413
|
+
path == f"/v1/operations/{PAYLOAD_INTERRUPTION_OPERATION_UUID}"
|
|
414
|
+
and method == "GET"
|
|
415
|
+
):
|
|
416
|
+
status = "EXECUTING" if attempt == 1 else "SUCCESS"
|
|
417
|
+
return json_reply(
|
|
418
|
+
200,
|
|
419
|
+
{
|
|
420
|
+
**OPERATION_RESPONSE,
|
|
421
|
+
"uuid": PAYLOAD_INTERRUPTION_OPERATION_UUID,
|
|
422
|
+
"status": status,
|
|
423
|
+
},
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if path == f"/v1/operations/{PAYLOAD_INTERRUPTION_OPERATION_UUID}/events":
|
|
427
|
+
if attempt == 1:
|
|
428
|
+
return Reply(
|
|
429
|
+
status=200,
|
|
430
|
+
content_type="text/event-stream",
|
|
431
|
+
stream_chunks=[sse_frame(EVENT_INIT), sse_frame(EVENT_SPAWN)],
|
|
432
|
+
truncate_chunked=True,
|
|
433
|
+
)
|
|
434
|
+
return Reply(
|
|
435
|
+
status=200,
|
|
436
|
+
content_type="text/event-stream",
|
|
437
|
+
stream_chunks=RESUMED_SSE_FRAMES,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if path == f"/v1/operations/{PAYLOAD_TIMEOUT_OPERATION_UUID}" and method == "GET":
|
|
441
|
+
return json_reply(
|
|
442
|
+
200,
|
|
443
|
+
{
|
|
444
|
+
**OPERATION_RESPONSE,
|
|
445
|
+
"uuid": PAYLOAD_TIMEOUT_OPERATION_UUID,
|
|
446
|
+
"status": "EXECUTING",
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if path == f"/v1/operations/{PAYLOAD_TIMEOUT_OPERATION_UUID}/events":
|
|
451
|
+
chunks = [sse_frame(EVENT_INIT)] if attempt == 1 else [b": keepalive\n\n"]
|
|
452
|
+
return Reply(
|
|
453
|
+
status=200,
|
|
454
|
+
content_type="text/event-stream",
|
|
455
|
+
stream_chunks=chunks,
|
|
456
|
+
truncate_chunked=True,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
path == f"/v1/operations/{PAYLOAD_STALLED_STATUS_OPERATION_UUID}"
|
|
461
|
+
and method == "GET"
|
|
462
|
+
):
|
|
463
|
+
time.sleep(STATUS_HANG_SECONDS)
|
|
464
|
+
return json_reply(
|
|
465
|
+
200,
|
|
466
|
+
{
|
|
467
|
+
**OPERATION_RESPONSE,
|
|
468
|
+
"uuid": PAYLOAD_STALLED_STATUS_OPERATION_UUID,
|
|
469
|
+
"status": "EXECUTING",
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if path == f"/v1/operations/{PAYLOAD_STALLED_STATUS_OPERATION_UUID}/events":
|
|
474
|
+
return Reply(
|
|
475
|
+
status=200,
|
|
476
|
+
content_type="text/event-stream",
|
|
477
|
+
stream_chunks=[b": keepalive\n\n"],
|
|
478
|
+
truncate_chunked=True,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if path == f"/v1/operations/{PAYLOAD_FORBIDDEN_OPERATION_UUID}/events":
|
|
482
|
+
return Reply(
|
|
483
|
+
status=403,
|
|
484
|
+
content_type="application/json",
|
|
485
|
+
stream_chunks=[b'{"error":"forbidden","status":403}'],
|
|
486
|
+
truncate_chunked=True,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if (
|
|
490
|
+
path == f"/v1/operations/{PAYLOAD_FINAL_STATUS_OPERATION_UUID}"
|
|
491
|
+
and method == "GET"
|
|
492
|
+
):
|
|
493
|
+
if attempt > 1:
|
|
494
|
+
time.sleep(STATUS_HANG_SECONDS)
|
|
495
|
+
return json_reply(
|
|
496
|
+
200,
|
|
497
|
+
{
|
|
498
|
+
**OPERATION_RESPONSE,
|
|
499
|
+
"uuid": PAYLOAD_FINAL_STATUS_OPERATION_UUID,
|
|
500
|
+
"status": "SUCCESS",
|
|
501
|
+
},
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if path == f"/v1/operations/{PAYLOAD_FINAL_STATUS_OPERATION_UUID}/events":
|
|
505
|
+
return Reply(
|
|
506
|
+
status=200,
|
|
507
|
+
content_type="text/event-stream",
|
|
508
|
+
stream_chunks=[b": keepalive\n\n"],
|
|
509
|
+
truncate_chunked=True,
|
|
510
|
+
)
|
|
511
|
+
|
|
403
512
|
if path == "/v1/images" and method == "GET":
|
|
404
513
|
if query.get("limit") == ["0"]:
|
|
405
514
|
return json_reply(
|
|
@@ -677,7 +786,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
677
786
|
self.send_header("Content-Length", str(len(payload)))
|
|
678
787
|
self.end_headers()
|
|
679
788
|
if method != "HEAD" and payload:
|
|
680
|
-
|
|
789
|
+
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
|
|
790
|
+
self.wfile.write(payload)
|
|
681
791
|
if reply.drop_after:
|
|
682
792
|
self.close_connection = True
|
|
683
793
|
|
|
@@ -708,9 +818,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
708
818
|
time.sleep(0.01)
|
|
709
819
|
if reply.hang:
|
|
710
820
|
time.sleep(reply.hang)
|
|
711
|
-
if not reply.
|
|
712
|
-
|
|
713
|
-
|
|
821
|
+
if not reply.truncate_chunked:
|
|
822
|
+
if not reply.truncate_gzip:
|
|
823
|
+
self.write_chunk(compressor.flush(zlib.Z_FINISH))
|
|
824
|
+
self.write_chunk(b"")
|
|
714
825
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
715
826
|
pass
|
|
716
827
|
finally:
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""aiohttp recovery from incomplete chunked operation-event responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib
|
|
7
|
+
import time
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from tests.stub_server import (
|
|
15
|
+
PAYLOAD_FINAL_STATUS_OPERATION_UUID,
|
|
16
|
+
PAYLOAD_FORBIDDEN_OPERATION_UUID,
|
|
17
|
+
PAYLOAD_INTERRUPTION_OPERATION_UUID,
|
|
18
|
+
PAYLOAD_STALLED_STATUS_OPERATION_UUID,
|
|
19
|
+
PAYLOAD_TIMEOUT_OPERATION_UUID,
|
|
20
|
+
StubServer,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_client_payload_error_is_retryable(
|
|
25
|
+
generated_package: ModuleType,
|
|
26
|
+
) -> None:
|
|
27
|
+
module = importlib.import_module("contree_client.aiohttp")
|
|
28
|
+
error = aiohttp.ClientPayloadError("incomplete chunked response")
|
|
29
|
+
|
|
30
|
+
assert isinstance(error, module.ContreeAsyncClient.retryable_errors)
|
|
31
|
+
assert not isinstance(error, module.ContreeAsyncClient.nonretryable_errors)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def collect_events(
|
|
35
|
+
base_url: str,
|
|
36
|
+
operation_id: str = PAYLOAD_INTERRUPTION_OPERATION_UUID,
|
|
37
|
+
timeout: float = 1.0,
|
|
38
|
+
) -> list[Any]:
|
|
39
|
+
module = importlib.import_module("contree_client.aiohttp")
|
|
40
|
+
async with module.ContreeAsyncClient("test-token", base_url=base_url) as client:
|
|
41
|
+
return [
|
|
42
|
+
event
|
|
43
|
+
async for event in client.follow_operation_events(
|
|
44
|
+
operation_id,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_follow_operation_events_resumes_after_incomplete_chunked_body(
|
|
51
|
+
generated_package: ModuleType,
|
|
52
|
+
stub_server: StubServer,
|
|
53
|
+
) -> None:
|
|
54
|
+
events = asyncio.run(collect_events(stub_server.base_url))
|
|
55
|
+
|
|
56
|
+
assert [event.id for event in events] == [0, 1, 2, 3]
|
|
57
|
+
event_requests = [
|
|
58
|
+
request
|
|
59
|
+
for request in stub_server.captured
|
|
60
|
+
if request.path
|
|
61
|
+
== f"/v1/operations/{PAYLOAD_INTERRUPTION_OPERATION_UUID}/events"
|
|
62
|
+
]
|
|
63
|
+
assert len(event_requests) == 2
|
|
64
|
+
assert event_requests[1].headers["last-event-id"] == "1"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_follow_operation_events_preserves_status_from_truncated_error_body(
|
|
68
|
+
generated_package: ModuleType,
|
|
69
|
+
stub_server: StubServer,
|
|
70
|
+
exceptions: ModuleType,
|
|
71
|
+
) -> None:
|
|
72
|
+
with pytest.raises(exceptions.ForbiddenError) as caught:
|
|
73
|
+
asyncio.run(
|
|
74
|
+
collect_events(
|
|
75
|
+
stub_server.base_url,
|
|
76
|
+
PAYLOAD_FORBIDDEN_OPERATION_UUID,
|
|
77
|
+
timeout=0.2,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert caught.value.status == 403
|
|
82
|
+
event_requests = [
|
|
83
|
+
request
|
|
84
|
+
for request in stub_server.captured
|
|
85
|
+
if request.path == f"/v1/operations/{PAYLOAD_FORBIDDEN_OPERATION_UUID}/events"
|
|
86
|
+
]
|
|
87
|
+
assert len(event_requests) == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def wait_operation(base_url: str, operation_id: str, timeout: float) -> Any:
|
|
91
|
+
module = importlib.import_module("contree_client.aiohttp")
|
|
92
|
+
async with module.ContreeAsyncClient("test-token", base_url=base_url) as client:
|
|
93
|
+
return await client.wait_operation(operation_id, timeout=timeout)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_wait_operation_resumes_after_incomplete_chunked_body(
|
|
97
|
+
generated_package: ModuleType,
|
|
98
|
+
stub_server: StubServer,
|
|
99
|
+
) -> None:
|
|
100
|
+
operation = asyncio.run(
|
|
101
|
+
wait_operation(
|
|
102
|
+
stub_server.base_url,
|
|
103
|
+
PAYLOAD_INTERRUPTION_OPERATION_UUID,
|
|
104
|
+
1.0,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
assert str(operation.status) == "SUCCESS"
|
|
109
|
+
event_requests = [
|
|
110
|
+
request
|
|
111
|
+
for request in stub_server.captured
|
|
112
|
+
if request.path
|
|
113
|
+
== f"/v1/operations/{PAYLOAD_INTERRUPTION_OPERATION_UUID}/events"
|
|
114
|
+
]
|
|
115
|
+
assert len(event_requests) == 2
|
|
116
|
+
assert event_requests[1].headers["last-event-id"] == "1"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_wait_operation_keeps_one_deadline_across_payload_interruptions(
|
|
120
|
+
generated_package: ModuleType,
|
|
121
|
+
stub_server: StubServer,
|
|
122
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
123
|
+
) -> None:
|
|
124
|
+
module = importlib.import_module("contree_client.base")
|
|
125
|
+
monkeypatch.setattr(module, "TIGHT_LOOP_FLOOR", 5.0)
|
|
126
|
+
started = time.monotonic()
|
|
127
|
+
with pytest.raises(TimeoutError, match=PAYLOAD_TIMEOUT_OPERATION_UUID):
|
|
128
|
+
asyncio.run(
|
|
129
|
+
wait_operation(
|
|
130
|
+
stub_server.base_url,
|
|
131
|
+
PAYLOAD_TIMEOUT_OPERATION_UUID,
|
|
132
|
+
0.2,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert time.monotonic() - started < 1.0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_wait_operation_deadline_bounds_terminal_status_probe(
|
|
140
|
+
generated_package: ModuleType,
|
|
141
|
+
stub_server: StubServer,
|
|
142
|
+
) -> None:
|
|
143
|
+
started = time.monotonic()
|
|
144
|
+
with pytest.raises(TimeoutError, match=PAYLOAD_STALLED_STATUS_OPERATION_UUID):
|
|
145
|
+
asyncio.run(
|
|
146
|
+
wait_operation(
|
|
147
|
+
stub_server.base_url,
|
|
148
|
+
PAYLOAD_STALLED_STATUS_OPERATION_UUID,
|
|
149
|
+
0.2,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assert time.monotonic() - started < 1.0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_wait_operation_deadline_bounds_final_status_fetch(
|
|
157
|
+
generated_package: ModuleType,
|
|
158
|
+
stub_server: StubServer,
|
|
159
|
+
) -> None:
|
|
160
|
+
started = time.monotonic()
|
|
161
|
+
with pytest.raises(TimeoutError, match=PAYLOAD_FINAL_STATUS_OPERATION_UUID):
|
|
162
|
+
asyncio.run(
|
|
163
|
+
wait_operation(
|
|
164
|
+
stub_server.base_url,
|
|
165
|
+
PAYLOAD_FINAL_STATUS_OPERATION_UUID,
|
|
166
|
+
0.2,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
assert time.monotonic() - started < 1.0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def follow_after_socket_timeout(delay: float, timeout: float) -> None:
|
|
174
|
+
module = importlib.import_module("contree_client.aiohttp")
|
|
175
|
+
client = module.ContreeAsyncClient("test-token", base_url="http://127.0.0.1")
|
|
176
|
+
|
|
177
|
+
async def interrupted_events(*args: Any, **kwargs: Any) -> Any:
|
|
178
|
+
await asyncio.sleep(delay)
|
|
179
|
+
raise aiohttp.SocketTimeoutError("read stalled")
|
|
180
|
+
yield # pragma: no cover - makes this an async generator
|
|
181
|
+
|
|
182
|
+
client.iter_operation_events = interrupted_events
|
|
183
|
+
try:
|
|
184
|
+
async for _event in client.follow_operation_events(
|
|
185
|
+
PAYLOAD_TIMEOUT_OPERATION_UUID,
|
|
186
|
+
timeout=timeout,
|
|
187
|
+
):
|
|
188
|
+
pass
|
|
189
|
+
finally:
|
|
190
|
+
await client.close()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_follow_operation_events_normalizes_socket_timeout_at_deadline(
|
|
194
|
+
generated_package: ModuleType,
|
|
195
|
+
) -> None:
|
|
196
|
+
with pytest.raises(TimeoutError, match=PAYLOAD_TIMEOUT_OPERATION_UUID):
|
|
197
|
+
asyncio.run(follow_after_socket_timeout(delay=0.05, timeout=0.01))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_follow_operation_events_preserves_socket_timeout_before_deadline(
|
|
201
|
+
generated_package: ModuleType,
|
|
202
|
+
) -> None:
|
|
203
|
+
with pytest.raises(aiohttp.SocketTimeoutError, match="read stalled"):
|
|
204
|
+
asyncio.run(follow_after_socket_timeout(delay=0.0, timeout=1.0))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def wait_after_socket_timeout(delay: float, timeout: float) -> None:
|
|
208
|
+
module = importlib.import_module("contree_client.aiohttp")
|
|
209
|
+
client = module.ContreeAsyncClient("test-token", base_url="http://127.0.0.1")
|
|
210
|
+
|
|
211
|
+
async def interrupted_events(*args: Any, **kwargs: Any) -> Any:
|
|
212
|
+
await asyncio.sleep(delay)
|
|
213
|
+
raise aiohttp.SocketTimeoutError("read stalled")
|
|
214
|
+
yield # pragma: no cover - makes this an async generator
|
|
215
|
+
|
|
216
|
+
client.iter_operation_events = interrupted_events
|
|
217
|
+
try:
|
|
218
|
+
await client.wait_operation(PAYLOAD_TIMEOUT_OPERATION_UUID, timeout=timeout)
|
|
219
|
+
finally:
|
|
220
|
+
await client.close()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_wait_operation_deadline_preempts_later_socket_timeout(
|
|
224
|
+
generated_package: ModuleType,
|
|
225
|
+
) -> None:
|
|
226
|
+
with pytest.raises(TimeoutError, match=PAYLOAD_TIMEOUT_OPERATION_UUID):
|
|
227
|
+
asyncio.run(wait_after_socket_timeout(delay=0.05, timeout=0.01))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_wait_operation_preserves_socket_timeout_before_its_deadline(
|
|
231
|
+
generated_package: ModuleType,
|
|
232
|
+
) -> None:
|
|
233
|
+
with pytest.raises(aiohttp.SocketTimeoutError, match="read stalled"):
|
|
234
|
+
asyncio.run(wait_after_socket_timeout(delay=0.0, timeout=1.0))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|