contree-client 0.2.1.dev0__tar.gz → 0.2.2__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 (43) hide show
  1. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/PKG-INFO +1 -1
  2. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/base.py +156 -26
  3. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/models.py +19 -8
  4. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/operations.py +5 -4
  5. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/runtime.py +13 -4
  6. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/spec_info.py +15 -6
  7. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/pyproject.toml +1 -1
  8. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/pyproject.toml.orig +1 -1
  9. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/stub_server.py +45 -0
  10. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_operation_helpers.py +49 -0
  11. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_runtime.py +14 -0
  12. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/LICENSE +0 -0
  13. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/README.md +0 -0
  14. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/__init__.py +0 -0
  15. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/aiohttp.py +0 -0
  16. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/asyncio.py +0 -0
  17. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/exceptions.py +0 -0
  18. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/http.py +0 -0
  19. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/httpx.py +0 -0
  20. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/profiles.py +0 -0
  21. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/py.typed +0 -0
  22. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/requests.py +0 -0
  23. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/sync.py +0 -0
  24. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/testing.py +0 -0
  25. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/types.py +0 -0
  26. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/contree_client/urllib3.py +0 -0
  27. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/conftest.py +0 -0
  28. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_aiohttp_payload_interruption.py +0 -0
  29. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_autodetect.py +0 -0
  30. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_clients.py +0 -0
  31. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_file_helpers.py +0 -0
  32. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_integration.py +0 -0
  33. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_interface.py +0 -0
  34. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_keepalive.py +0 -0
  35. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_logging.py +0 -0
  36. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_models.py +0 -0
  37. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_operations.py +0 -0
  38. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_pagination.py +0 -0
  39. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_profiles.py +0 -0
  40. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_retry.py +0 -0
  41. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_sse.py +0 -0
  42. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/tests/test_stream_lifecycle.py +0 -0
  43. {contree_client-0.2.1.dev0 → contree_client-0.2.2}/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.2.2
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>
@@ -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,
@@ -601,9 +601,12 @@ class StreamRepr(ContreeModel):
601
601
 
602
602
  @dataclass
603
603
  class ClosableStreamRepr(ContreeModel):
604
- """Stdin payload. Unlike output streams it is never truncated;.
604
+ """Stdin payload. Delivery to the child is always complete —.
605
605
 
606
- instead it carries a ``close`` flag controlling the pipe's EOF.
606
+ stdin is never truncated on its way to the process. Only the
607
+ *recorded* ``stdin`` events in the subprocess event log are
608
+ capped by ``truncate_output_at``, same as stdout/stderr. This
609
+ schema also carries a ``close`` flag controlling the pipe's EOF.
607
610
  """
608
611
 
609
612
  value: str = field(
@@ -732,8 +735,10 @@ class ExecSpec(ContreeModel):
732
735
  default=...,
733
736
  metadata={
734
737
  "description": (
735
- "Maximum number of bytes of stdout / stderr to keep.\nDefaults to 1 "
736
- "MiB; capped at 10 MiB."
738
+ "Maximum number of bytes to keep per stream (stdout,\nstderr, and "
739
+ "stdin) in the subprocess event log. Does not\naffect delivery to the "
740
+ "child — stdin is always delivered\nin full regardless of this cap. "
741
+ "Defaults to 1 MiB; capped\nat 10 MiB."
737
742
  ),
738
743
  "example": 65535,
739
744
  "default": 1048576,
@@ -904,8 +909,11 @@ class InstanceSpawnRequest(ContreeModel):
904
909
  default=...,
905
910
  metadata={
906
911
  "description": (
907
- "Maximum number of bytes to keep from stdout/stderr. Default is "
908
- "`1048576` (1 MiB). The maximum value is `10485760` (10 MiB)."
912
+ "Maximum number of bytes to keep per stream (stdout, stderr, and "
913
+ "stdin) in the subprocess event log. Does not affect stdin delivery to "
914
+ "the child, which always receives the complete stream regardless of "
915
+ "this cap. Default is `1048576` (1 MiB). The maximum value is "
916
+ "`10485760` (10 MiB)."
909
917
  ),
910
918
  "example": 65535,
911
919
  "default": 1048576,
@@ -1357,8 +1365,11 @@ class OperationInstanceMetadata(ContreeModel):
1357
1365
  default=...,
1358
1366
  metadata={
1359
1367
  "description": (
1360
- "Maximum number of bytes to keep from stdout/stderr. Default is "
1361
- "`1048576` (1 MiB). The maximum value is `10485760` (10 MiB)."
1368
+ "Maximum number of bytes to keep per stream (stdout, stderr, and "
1369
+ "stdin) in the subprocess event log. Does not affect stdin delivery to "
1370
+ "the child, which always receives the complete stream regardless of "
1371
+ "this cap. Default is `1048576` (1 MiB). The maximum value is "
1372
+ "`10485760` (10 MiB)."
1362
1373
  ),
1363
1374
  "example": 65535,
1364
1375
  "default": 1048576,
@@ -6,6 +6,7 @@ Generated by codegen from the OpenAPI spec - do not edit.
6
6
  from __future__ import annotations
7
7
 
8
8
  import json
9
+ from collections.abc import Sequence
9
10
  from datetime import datetime
10
11
  from types import EllipsisType
11
12
  from typing import IO, Any, Literal
@@ -591,10 +592,10 @@ def parse_inspect_image_list(response: ResponseData) -> DirectoryList:
591
592
 
592
593
  def build_inspect_image_grep(
593
594
  image_uuid: str,
594
- pattern: str,
595
+ pattern: str | Sequence[str],
595
596
  *,
596
- path: str | None = None,
597
- glob: str | None = None,
597
+ path: str | Sequence[str] | None = None,
598
+ glob: str | Sequence[str] | None = None,
598
599
  max_count: int | None = None,
599
600
  max_total: int | None = None,
600
601
  case: Literal["sensitive", "insensitive", "smart"] | None = None,
@@ -602,7 +603,7 @@ def build_inspect_image_grep(
602
603
  after: int | None = None,
603
604
  ) -> RequestSpec:
604
605
  """Build the request for `GET /inspect/{image_uuid}/grep`."""
605
- query: dict[str, str] = {}
606
+ query: dict[str, str | Sequence[str]] = {}
606
607
  query["pattern"] = pattern
607
608
  if path is not None:
608
609
  query["path"] = path
@@ -9,7 +9,7 @@ import logging
9
9
  import math
10
10
  import re
11
11
  import zlib
12
- from collections.abc import AsyncIterator, Iterable, Iterator
12
+ from collections.abc import AsyncIterator, Iterable, Iterator, Mapping, Sequence
13
13
  from dataclasses import dataclass, field
14
14
  from datetime import datetime, timezone
15
15
  from email.utils import parsedate_to_datetime
@@ -146,7 +146,7 @@ class RequestSpec:
146
146
 
147
147
  method: str
148
148
  path: str
149
- query: dict[str, str] = field(default_factory=dict)
149
+ query: Mapping[str, str | Sequence[str]] = field(default_factory=dict)
150
150
  headers: dict[str, str] = field(default_factory=dict)
151
151
  body: bytes | IO[bytes] | None = None
152
152
  content_type: str | None = None
@@ -247,6 +247,15 @@ RETRY_DELAYS = (0.1, 0.2, 0.5, 1.0, 2.0, 5.0)
247
247
  # returning immediate empty streams does not spin the client
248
248
  TIGHT_LOOP_FLOOR = 0.5
249
249
 
250
+ # the events route itself doesn't exist for this operation/server
251
+ # (malformed request an older backend rejects, or a reverse proxy
252
+ # that never forwards it) rather than merely being down: reconnecting
253
+ # will never succeed, but the operation itself may still complete -
254
+ # degrade to polling instead of failing the whole wait. 401/403 are
255
+ # deliberately excluded: an auth/permission failure here likely means
256
+ # the whole client is broken, not just this route, so it still raises
257
+ EVENTS_UNAVAILABLE_STATUSES = frozenset({400, 404, 405, 406})
258
+
250
259
 
251
260
  def retry_generator(delays: tuple[float, ...] = RETRY_DELAYS) -> Iterator[float]:
252
261
  """An endless ladder of backoff delays.
@@ -370,8 +379,8 @@ def quote_path(value: str | int) -> str:
370
379
  return quote(str(value), safe="")
371
380
 
372
381
 
373
- def encode_query(query: dict[str, str]) -> str:
374
- return urlencode(query, safe="/", quote_via=quote)
382
+ def encode_query(query: Mapping[str, str | Sequence[str]]) -> str:
383
+ return urlencode(query, doseq=True, safe="/", quote_via=quote)
375
384
 
376
385
 
377
386
  def format_time_param(value: str | int | float | datetime) -> str:
@@ -2146,8 +2146,11 @@ Generated by codegen from the OpenAPI spec - do not edit.
2146
2146
  ClosableStreamRepr:
2147
2147
  type: object
2148
2148
  description: |
2149
- Stdin payload. Unlike output streams it is never truncated;
2150
- instead it carries a `close` flag controlling the pipe's EOF.
2149
+ Stdin payload. Delivery to the child is always complete —
2150
+ stdin is never truncated on its way to the process. Only the
2151
+ *recorded* `stdin` events in the subprocess event log are
2152
+ capped by `truncate_output_at`, same as stdout/stderr. This
2153
+ schema also carries a `close` flag controlling the pipe's EOF.
2151
2154
  required:
2152
2155
  - value
2153
2156
  properties:
@@ -2251,8 +2254,11 @@ Generated by codegen from the OpenAPI spec - do not edit.
2251
2254
  truncate_output_at:
2252
2255
  type: integer
2253
2256
  description: |
2254
- Maximum number of bytes of stdout / stderr to keep.
2255
- Defaults to 1 MiB; capped at 10 MiB.
2257
+ Maximum number of bytes to keep per stream (stdout,
2258
+ stderr, and stdin) in the subprocess event log. Does not
2259
+ affect delivery to the child — stdin is always delivered
2260
+ in full regardless of this cap. Defaults to 1 MiB; capped
2261
+ at 10 MiB.
2256
2262
  default: 1048576
2257
2263
  example: 65535
2258
2264
 
@@ -2361,7 +2367,10 @@ Generated by codegen from the OpenAPI spec - do not edit.
2361
2367
  truncate_output_at:
2362
2368
  type: integer
2363
2369
  description: >
2364
- Maximum number of bytes to keep from stdout/stderr.
2370
+ Maximum number of bytes to keep per stream (stdout,
2371
+ stderr, and stdin) in the subprocess event log.
2372
+ Does not affect stdin delivery to the child, which always
2373
+ receives the complete stream regardless of this cap.
2365
2374
  Default is `1048576` (1 MiB).
2366
2375
  The maximum value is `10485760` (10 MiB).
2367
2376
  example: 65535
@@ -3446,4 +3455,4 @@ DEFAULT_BASE_URL = "https://api.tokenfactory.nebius.com/sandboxes"
3446
3455
 
3447
3456
  # sha256 of the exact OpenAPI document this package was built from -
3448
3457
  # the build input provenance
3449
- SPEC_SHA256 = "2bfd239341e6f61ccba1aafb39cef7b476d56f424156860a204f089d7d79b7bf"
3458
+ SPEC_SHA256 = "23d748a2918b1124b3072d54d17d8eb43aa00d0b969bc1fa718f34ac884ee887"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "contree-client"
7
- version = "0.2.1.dev0"
7
+ version = "0.2.2"
8
8
  description = "Python client for the Contree API, generated from the OpenAPI specification"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@ build-backend = "uv_build"
6
6
 
7
7
  [project]
8
8
  name = "contree-client"
9
- version = "0.2.1.dev0"
9
+ version = "0.2.2"
10
10
  description = "Python client for the Contree API, generated from the OpenAPI specification"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -41,6 +41,8 @@ CLOSING_OPERATION_UUID = "00000000-0000-0000-0000-0000000c105e"
41
41
  DROPPING_OPERATION_UUID = "00000000-0000-0000-0000-00000000d40b"
42
42
  KEEPALIVE_OPERATION_UUID = "00000000-0000-0000-0000-0000000cee9a"
43
43
  RESET_OPERATION_UUID = "00000000-0000-0000-0000-000000000e5e"
44
+ EVENTS_UNAVAILABLE_OPERATION_UUID = "00000000-0000-0000-0000-00000000eee0"
45
+ EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID = "00000000-0000-0000-0000-00000000eee1"
44
46
  SSE_HANG_SECONDS = 10.0
45
47
  STATUS_HANG_SECONDS = 2.0
46
48
  FILE_UUID = "a9165a5d-5c86-4bd8-8ee4-ae46c19cf45d"
@@ -409,6 +411,49 @@ def route(request: Captured, attempts: collections.Counter[str]) -> Reply:
409
411
  stream_chunks=chunks,
410
412
  )
411
413
 
414
+ if (
415
+ path == f"/v1/operations/{EVENTS_UNAVAILABLE_OPERATION_UUID}"
416
+ and method == "GET"
417
+ ):
418
+ # the operation itself progresses and finishes normally; only
419
+ # its /events route is missing
420
+ status = "EXECUTING" if attempt < 3 else "SUCCESS"
421
+ return json_reply(
422
+ 200,
423
+ {
424
+ **OPERATION_RESPONSE,
425
+ "uuid": EVENTS_UNAVAILABLE_OPERATION_UUID,
426
+ "status": status,
427
+ },
428
+ )
429
+
430
+ if path == f"/v1/operations/{EVENTS_UNAVAILABLE_OPERATION_UUID}/events":
431
+ # no /events route at all for this operation (older backend,
432
+ # or a reverse proxy that doesn't forward it): reconnecting
433
+ # this exact request will never succeed, but the operation
434
+ # itself is healthy - the client must fall back to polling
435
+ # get_operation_status instead of failing the whole wait
436
+ return json_reply(404, {"error": "not found", "status": 404})
437
+
438
+ if (
439
+ path == f"/v1/operations/{EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID}"
440
+ and method == "GET"
441
+ ):
442
+ # same missing-events situation, but the operation itself
443
+ # never finishes - the polling fallback must still honor the
444
+ # caller's deadline instead of looping forever
445
+ return json_reply(
446
+ 200,
447
+ {
448
+ **OPERATION_RESPONSE,
449
+ "uuid": EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID,
450
+ "status": "EXECUTING",
451
+ },
452
+ )
453
+
454
+ if path == f"/v1/operations/{EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID}/events":
455
+ return json_reply(404, {"error": "not found", "status": 404})
456
+
412
457
  if (
413
458
  path == f"/v1/operations/{PAYLOAD_INTERRUPTION_OPERATION_UUID}"
414
459
  and method == "GET"
@@ -3,11 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
+ from types import ModuleType
6
7
  from typing import Any
7
8
 
8
9
  import pytest
9
10
 
10
11
  from tests.stub_server import (
12
+ EVENTS_UNAVAILABLE_OPERATION_UUID,
13
+ EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID,
11
14
  EXECUTING_OPERATION_UUID,
12
15
  IMAGE_UUID,
13
16
  PENDING_OPERATION_UUID,
@@ -62,6 +65,52 @@ def test_follow_operation_events_reconnects(
62
65
  assert stream_requests[1].headers["last-event-id"] == "1"
63
66
 
64
67
 
68
+ def test_wait_operation_falls_back_to_polling_when_events_missing(
69
+ invoke: Callable[..., Any], stub_server: StubServer
70
+ ) -> None:
71
+ # /events 404s outright (older backend, proxy that drops the
72
+ # route, ...); the operation itself still finishes
73
+ operation = invoke("wait_operation", EVENTS_UNAVAILABLE_OPERATION_UUID)
74
+
75
+ assert str(operation.status) == "SUCCESS"
76
+ events_path = f"/v1/operations/{EVENTS_UNAVAILABLE_OPERATION_UUID}/events"
77
+ events_requests = [c for c in stub_server.captured if c.path == events_path]
78
+ # a 404 means reconnecting will never work: touched exactly once
79
+ assert len(events_requests) == 1
80
+ status_path = f"/v1/operations/{EVENTS_UNAVAILABLE_OPERATION_UUID}"
81
+ status_requests = [c for c in stub_server.captured if c.path == status_path]
82
+ # polled until terminal, plus wait_operation's own final fetch
83
+ assert len(status_requests) >= 3
84
+
85
+
86
+ def test_follow_operation_events_yields_completion_when_events_missing(
87
+ invoke: Callable[..., Any], stub_server: StubServer, models: ModuleType
88
+ ) -> None:
89
+ # there is no event log to relay, but the caller must still see a
90
+ # terminal completion event, not an iterator that silently ends
91
+ events = invoke(
92
+ "follow_operation_events", EVENTS_UNAVAILABLE_OPERATION_UUID, collect=True
93
+ )
94
+
95
+ assert len(events) == 1
96
+ assert events[0].type == "completion"
97
+ assert isinstance(events[0].data, models.EventDataCompletion)
98
+ assert str(events[0].data.status) == "SUCCESS"
99
+
100
+
101
+ def test_wait_operation_polling_fallback_respects_timeout(
102
+ invoke: Callable[..., Any], stub_server: StubServer
103
+ ) -> None:
104
+ # events unavailable and the operation never finishes: the polling
105
+ # fallback must still honor the deadline instead of spinning forever
106
+ with pytest.raises(TimeoutError, match=EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID):
107
+ invoke(
108
+ "wait_operation",
109
+ EVENTS_UNAVAILABLE_STALLED_OPERATION_UUID,
110
+ timeout=0.3,
111
+ )
112
+
113
+
65
114
  def test_resolve_image_tag_prefix(
66
115
  invoke: Callable[..., Any], stub_server: StubServer
67
116
  ) -> None:
@@ -26,6 +26,20 @@ def test_format_time_param(runtime: ModuleType) -> None:
26
26
  assert runtime.format_time_param("1h") == "1h"
27
27
 
28
28
 
29
+ def test_encode_query_preserves_repeated_values(runtime: ModuleType) -> None:
30
+ assert (
31
+ runtime.encode_query(
32
+ {
33
+ "pattern": ["^root:", "^bin:"],
34
+ "path": ("/etc/passwd", "/etc/group"),
35
+ "case": "sensitive",
36
+ }
37
+ )
38
+ == "pattern=%5Eroot%3A&pattern=%5Ebin%3A"
39
+ "&path=/etc/passwd&path=/etc/group&case=sensitive"
40
+ )
41
+
42
+
29
43
  def test_json_object_rejects_array(
30
44
  runtime: ModuleType,
31
45
  exceptions: ModuleType,