Skip to content

Commit e38c887

Browse files
feat: chore(stlc): seal custom-code tracking files
Stainless-Generated-From: fce86e75c1181e24c4e991e93be3817902ec3ab4
1 parent fdeb71f commit e38c887

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import math
4+
import time
5+
import random
6+
from datetime import datetime
7+
8+
from .._types import Omit, Headers
9+
from .._exceptions import KernelError
10+
from ..types.config_registry_response import ConfigRegistryResponse
11+
12+
DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL = 5.0
13+
_TERMINAL_STATUSES = frozenset({"completed", "failed", "canceled", "expired"})
14+
15+
16+
def validate_wait_options(poll_interval: float, max_wait_seconds: float | None) -> None:
17+
if not math.isfinite(poll_interval) or poll_interval <= 0:
18+
raise ValueError("Expected a finite, positive value for `poll_interval`")
19+
if max_wait_seconds is not None and (not math.isfinite(max_wait_seconds) or max_wait_seconds < 0):
20+
raise ValueError("Expected a finite, non-negative value for `max_wait_seconds`")
21+
22+
23+
def poll_headers(extra_headers: Headers | None) -> Headers:
24+
headers: dict[str, str | Omit] = dict(extra_headers or {})
25+
headers["X-Stainless-Poll-Helper"] = "true"
26+
return headers
27+
28+
29+
def poll_delay(poll_interval: float) -> float:
30+
return poll_interval * random.uniform(0.9, 1.1)
31+
32+
33+
def wait_timeout_error(id: str, polls: int, last_status: str | None, started_at: float) -> TimeoutError:
34+
elapsed = time.monotonic() - started_at
35+
return TimeoutError(
36+
f"Timed out waiting for config registry analysis {id!r} after {elapsed:.1f}s "
37+
f"and {polls} polls; last status was {last_status!r}"
38+
)
39+
40+
41+
def analysis_finished(response: ConfigRegistryResponse, requested_id: str) -> tuple[bool, str]:
42+
analysis = response.analysis
43+
if analysis is None:
44+
raise KernelError(f"Config registry response for {requested_id!r} is missing an analysis")
45+
46+
analysis_id = getattr(analysis, "id", None)
47+
if not isinstance(analysis_id, str) or not analysis_id:
48+
raise KernelError(f"Config registry response for {requested_id!r} has no valid analysis ID")
49+
if analysis_id != requested_id:
50+
raise KernelError(f"Config registry response for {requested_id!r} returned analysis {analysis_id!r}")
51+
52+
status = getattr(analysis, "status", None)
53+
if not isinstance(status, str) or not status:
54+
raise KernelError(f"Config registry analysis {requested_id!r} has no valid status")
55+
56+
if "finished_at" not in analysis.model_fields_set:
57+
raise KernelError(f"Config registry analysis {requested_id!r} is missing `finished_at`")
58+
59+
finished_at = getattr(analysis, "finished_at", None)
60+
if finished_at is not None and not isinstance(finished_at, datetime):
61+
raise KernelError(f"Config registry analysis {requested_id!r} has an invalid `finished_at`")
62+
63+
return finished_at is not None or status in _TERMINAL_STATUSES, status

src/kernel/resources/config_registry/analyses.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import time
6+
57
import httpx
68

79
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
@@ -18,6 +20,14 @@
1820
from ..._base_client import AsyncPaginator, make_request_options
1921
from ...types.config_registry import analysis_list_params
2022
from ...types.analysis_summary import AnalysisSummary
23+
from ...lib.config_registry_wait import (
24+
DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL,
25+
poll_delay,
26+
poll_headers,
27+
analysis_finished,
28+
wait_timeout_error,
29+
validate_wait_options,
30+
)
2131
from ...types.config_registry_response import ConfigRegistryResponse
2232

2333
__all__ = ["AnalysesResource", "AsyncAnalysesResource"]
@@ -79,6 +89,54 @@ def retrieve(
7989
cast_to=ConfigRegistryResponse,
8090
)
8191

92+
def wait_for_result(
93+
self,
94+
id: str,
95+
*,
96+
poll_interval: float = DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL,
97+
max_wait_seconds: float | None = None,
98+
extra_headers: Headers | None = None,
99+
extra_query: Query | None = None,
100+
extra_body: Body | None = None,
101+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
102+
) -> ConfigRegistryResponse:
103+
"""Wait for an analysis to finish and return its complete result.
104+
105+
The first retrieval happens immediately. ``max_wait_seconds`` is a soft
106+
polling deadline: an in-flight request and its normal retries may finish
107+
after it. Timing out does not cancel the remote analysis.
108+
"""
109+
validate_wait_options(poll_interval, max_wait_seconds)
110+
started_at = time.monotonic()
111+
deadline = started_at + max_wait_seconds if max_wait_seconds is not None else None
112+
headers = poll_headers(extra_headers)
113+
polls = 0
114+
last_status: str | None = None
115+
116+
while True:
117+
if polls > 0 and deadline is not None and time.monotonic() >= deadline:
118+
raise wait_timeout_error(id, polls, last_status, started_at)
119+
120+
response = self.retrieve(
121+
id,
122+
extra_headers=headers,
123+
extra_query=extra_query,
124+
extra_body=extra_body,
125+
timeout=timeout,
126+
)
127+
polls += 1
128+
finished, last_status = analysis_finished(response, id)
129+
if finished:
130+
return response
131+
132+
delay = poll_delay(poll_interval)
133+
if deadline is not None:
134+
remaining = deadline - time.monotonic()
135+
if remaining <= 0:
136+
raise wait_timeout_error(id, polls, last_status, started_at)
137+
delay = min(delay, remaining)
138+
self._sleep(delay)
139+
82140
def list(
83141
self,
84142
*,
@@ -220,6 +278,55 @@ async def retrieve(
220278
cast_to=ConfigRegistryResponse,
221279
)
222280

281+
async def wait_for_result(
282+
self,
283+
id: str,
284+
*,
285+
poll_interval: float = DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL,
286+
max_wait_seconds: float | None = None,
287+
extra_headers: Headers | None = None,
288+
extra_query: Query | None = None,
289+
extra_body: Body | None = None,
290+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
291+
) -> ConfigRegistryResponse:
292+
"""Wait for an analysis to finish and return its complete result.
293+
294+
The first retrieval happens immediately. ``max_wait_seconds`` is a soft
295+
polling deadline: an in-flight request and its normal retries may finish
296+
after it. Timing out does not cancel the remote analysis. Cancelling the
297+
calling task stops the wait without cancelling the remote analysis.
298+
"""
299+
validate_wait_options(poll_interval, max_wait_seconds)
300+
started_at = time.monotonic()
301+
deadline = started_at + max_wait_seconds if max_wait_seconds is not None else None
302+
headers = poll_headers(extra_headers)
303+
polls = 0
304+
last_status: str | None = None
305+
306+
while True:
307+
if polls > 0 and deadline is not None and time.monotonic() >= deadline:
308+
raise wait_timeout_error(id, polls, last_status, started_at)
309+
310+
response = await self.retrieve(
311+
id,
312+
extra_headers=headers,
313+
extra_query=extra_query,
314+
extra_body=extra_body,
315+
timeout=timeout,
316+
)
317+
polls += 1
318+
finished, last_status = analysis_finished(response, id)
319+
if finished:
320+
return response
321+
322+
delay = poll_delay(poll_interval)
323+
if deadline is not None:
324+
remaining = deadline - time.monotonic()
325+
if remaining <= 0:
326+
raise wait_timeout_error(id, polls, last_status, started_at)
327+
delay = min(delay, remaining)
328+
await self._sleep(delay)
329+
223330
def list(
224331
self,
225332
*,

tests/test_config_registry_wait.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Any
5+
from collections.abc import Iterator
6+
7+
import httpx
8+
import pytest
9+
10+
from kernel import Kernel, AsyncKernel
11+
from kernel._exceptions import KernelError
12+
13+
14+
def analysis_response(
15+
status: str,
16+
*,
17+
analysis_id: str = "analysis-1",
18+
finished_at: str | None = None,
19+
) -> dict[str, Any]:
20+
return {
21+
"analysis": {
22+
"id": analysis_id,
23+
"created_at": "2026-09-16T00:00:00Z",
24+
"expires_at": "2026-09-16T00:45:00Z",
25+
"failure": None,
26+
"finished_at": finished_at,
27+
"status": status,
28+
"intent": None,
29+
},
30+
"recommendation": None,
31+
"target": {
32+
"domain": "example.com",
33+
"host": "example.com",
34+
"normalized": "https://example.com/",
35+
},
36+
"working_configurations": [],
37+
"guidance": None,
38+
"workload_outcome": None,
39+
}
40+
41+
42+
def response_sequence(payloads: list[dict[str, Any]]) -> tuple[httpx.MockTransport, list[httpx.Request]]:
43+
remaining: Iterator[dict[str, Any]] = iter(payloads)
44+
requests: list[httpx.Request] = []
45+
46+
def handler(request: httpx.Request) -> httpx.Response:
47+
requests.append(request)
48+
return httpx.Response(200, json=next(remaining))
49+
50+
return httpx.MockTransport(handler), requests
51+
52+
53+
def test_wait_for_result_polls_unknown_unfinished_status_until_finished() -> None:
54+
transport, requests = response_sequence(
55+
[
56+
analysis_response("running"),
57+
analysis_response("queued"),
58+
analysis_response("archived", finished_at="2026-09-16T00:01:00Z"),
59+
]
60+
)
61+
62+
with httpx.Client(transport=transport) as http_client:
63+
client = Kernel(api_key="test", base_url="https://api.example", http_client=http_client)
64+
result = client.config_registry.analyses.wait_for_result(
65+
"analysis-1",
66+
poll_interval=0.001,
67+
extra_headers={"X-Test": "preserved", "X-Stainless-Poll-Helper": "caller"},
68+
)
69+
70+
assert result.analysis is not None
71+
assert result.analysis.status == "archived"
72+
assert len(requests) == 3
73+
assert all(request.headers["X-Test"] == "preserved" for request in requests)
74+
assert all(request.headers["X-Stainless-Poll-Helper"] == "true" for request in requests)
75+
76+
77+
@pytest.mark.parametrize("status", ["completed", "failed", "canceled", "expired"])
78+
def test_wait_for_result_returns_known_terminal_status_without_finished_at(status: str) -> None:
79+
transport, requests = response_sequence([analysis_response(status)])
80+
81+
with httpx.Client(transport=transport) as http_client:
82+
client = Kernel(api_key="test", base_url="https://api.example", http_client=http_client)
83+
result = client.config_registry.analyses.wait_for_result("analysis-1")
84+
85+
assert result.analysis is not None
86+
assert result.analysis.status == status
87+
assert len(requests) == 1
88+
89+
90+
def test_wait_for_result_zero_max_wait_reads_once_then_times_out() -> None:
91+
transport, requests = response_sequence([analysis_response("running")])
92+
93+
with httpx.Client(transport=transport) as http_client:
94+
client = Kernel(api_key="test", base_url="https://api.example", http_client=http_client)
95+
with pytest.raises(TimeoutError, match="analysis-1"):
96+
client.config_registry.analyses.wait_for_result("analysis-1", max_wait_seconds=0)
97+
98+
assert len(requests) == 1
99+
100+
101+
@pytest.mark.parametrize(
102+
"payload, message",
103+
[
104+
({"analysis": None}, "missing an analysis"),
105+
(analysis_response("running", analysis_id="analysis-2"), "analysis-2"),
106+
],
107+
)
108+
def test_wait_for_result_rejects_invalid_analysis_response(payload: dict[str, Any], message: str) -> None:
109+
transport, _ = response_sequence([payload])
110+
111+
with httpx.Client(transport=transport) as http_client:
112+
client = Kernel(api_key="test", base_url="https://api.example", http_client=http_client)
113+
with pytest.raises(KernelError, match=message):
114+
client.config_registry.analyses.wait_for_result("analysis-1")
115+
116+
117+
def test_wait_for_result_rejects_missing_finished_at() -> None:
118+
payload = analysis_response("running")
119+
analysis = payload["analysis"]
120+
assert isinstance(analysis, dict)
121+
del analysis["finished_at"]
122+
transport, _ = response_sequence([payload])
123+
124+
with httpx.Client(transport=transport) as http_client:
125+
client = Kernel(api_key="test", base_url="https://api.example", http_client=http_client)
126+
with pytest.raises(KernelError, match="finished_at"):
127+
client.config_registry.analyses.wait_for_result("analysis-1")
128+
129+
130+
@pytest.mark.parametrize(
131+
"kwargs",
132+
[
133+
{"poll_interval": 0},
134+
{"poll_interval": float("nan")},
135+
{"max_wait_seconds": -1},
136+
{"max_wait_seconds": float("inf")},
137+
],
138+
)
139+
def test_wait_for_result_rejects_invalid_timing_options(kwargs: dict[str, float]) -> None:
140+
client = Kernel(api_key="test", base_url="https://api.example")
141+
142+
with pytest.raises(ValueError):
143+
client.config_registry.analyses.wait_for_result("analysis-1", **kwargs) # type: ignore[arg-type]
144+
145+
146+
async def test_async_wait_for_result_matches_sync_behavior() -> None:
147+
transport, requests = response_sequence(
148+
[
149+
analysis_response("running"),
150+
analysis_response("completed", finished_at="2026-09-16T00:01:00Z"),
151+
]
152+
)
153+
154+
async with httpx.AsyncClient(transport=transport) as http_client:
155+
client = AsyncKernel(api_key="test", base_url="https://api.example", http_client=http_client)
156+
result = await client.config_registry.analyses.wait_for_result("analysis-1", poll_interval=0.001)
157+
158+
assert result.analysis is not None
159+
assert result.analysis.status == "completed"
160+
assert len(requests) == 2
161+
162+
163+
async def test_async_wait_for_result_is_cancellable_during_poll_sleep() -> None:
164+
first_request = asyncio.Event()
165+
requests: list[httpx.Request] = []
166+
167+
def handler(request: httpx.Request) -> httpx.Response:
168+
requests.append(request)
169+
first_request.set()
170+
return httpx.Response(200, json=analysis_response("running"))
171+
172+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
173+
client = AsyncKernel(api_key="test", base_url="https://api.example", http_client=http_client)
174+
task = asyncio.create_task(client.config_registry.analyses.wait_for_result("analysis-1", poll_interval=60))
175+
await first_request.wait()
176+
await asyncio.sleep(0)
177+
task.cancel()
178+
with pytest.raises(asyncio.CancelledError):
179+
await task
180+
181+
assert len(requests) == 1

0 commit comments

Comments
 (0)