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.
Files changed (43) hide show
  1. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/PKG-INFO +1 -1
  2. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/aiohttp.py +7 -1
  3. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/base.py +92 -10
  4. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/models.py +26 -3
  5. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/operations.py +6 -0
  6. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/spec_info.py +46 -3
  7. contree_client-0.2.1.dev0/pyproject.toml +78 -0
  8. contree_client-0.2.0/pyproject.toml → contree_client-0.2.1.dev0/pyproject.toml.orig +1 -1
  9. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/stub_server.py +115 -4
  10. contree_client-0.2.1.dev0/tests/test_aiohttp_payload_interruption.py +234 -0
  11. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/LICENSE +0 -0
  12. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/README.md +0 -0
  13. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/__init__.py +0 -0
  14. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/asyncio.py +0 -0
  15. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/exceptions.py +0 -0
  16. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/http.py +0 -0
  17. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/httpx.py +0 -0
  18. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/profiles.py +0 -0
  19. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/py.typed +0 -0
  20. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/requests.py +0 -0
  21. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/runtime.py +0 -0
  22. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/sync.py +0 -0
  23. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/testing.py +0 -0
  24. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/types.py +0 -0
  25. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/contree_client/urllib3.py +0 -0
  26. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/conftest.py +0 -0
  27. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_autodetect.py +0 -0
  28. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_clients.py +0 -0
  29. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_file_helpers.py +0 -0
  30. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_integration.py +0 -0
  31. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_interface.py +0 -0
  32. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_keepalive.py +0 -0
  33. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_logging.py +0 -0
  34. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_models.py +0 -0
  35. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_operation_helpers.py +0 -0
  36. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_operations.py +0 -0
  37. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_pagination.py +0 -0
  38. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_profiles.py +0 -0
  39. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_retry.py +0 -0
  40. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_runtime.py +0 -0
  41. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_sse.py +0 -0
  42. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/tests/test_stream_lifecycle.py +0 -0
  43. {contree_client-0.2.0 → contree_client-0.2.1.dev0}/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.0
3
+ Version: 0.2.1.dev0
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>
@@ -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
- body = await response.read()
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. Defaults to
1647
- the rootfs root. Example: ``/etc``.
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
- async for event in self.follow_operation_events(operation_id, timeout=timeout):
1807
- self.log.debug("wait_operation: event %s %s", event.id, event.type)
1808
- return await self.get_operation_status(operation_id)
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 self.operation_terminal(operation_id):
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 self.operation_terminal(operation_id):
1946
+ if await operation_terminal_before_deadline():
1891
1947
  return
1892
1948
  if last_id == events_before:
1893
- await asyncio.sleep(TIGHT_LOOP_FLOOR)
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. Defaults to
3119
- the rootfs root. Example: ``/etc``.
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": "The path inside the image that was searched",
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: File or directory to search inside the image. Defaults to the rootfs root.
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: The path inside the image that was searched
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 = "bf17dbf27d28bebe3e23b295835bbdb2927ab9de5e9ae581af675e90986acc56"
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"]
@@ -6,7 +6,7 @@ build-backend = "uv_build"
6
6
 
7
7
  [project]
8
8
  name = "contree-client"
9
- version = "0.2.0"
9
+ version = "0.2.1.dev0"
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"
@@ -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
- self.wfile.write(payload)
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.truncate_gzip:
712
- self.write_chunk(compressor.flush(zlib.Z_FINISH))
713
- self.write_chunk(b"")
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))