Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.107.0"
".": "0.108.0"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [0.108.0](https://github.com/kernel/kernel-python-sdk/compare/v0.107.0...v0.108.0) (2026-09-17)


### Features

* feat: add config registry analysis waiter ([e38c887](https://github.com/kernel/kernel-python-sdk/commit/e38c8878f9f80f5d28fce9f92df6f0a18a48e2bb))

## [0.107.0](https://github.com/kernel/kernel-python-sdk/compare/v0.106.0...v0.107.0) (2026-09-16)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "kernel"
version = "0.107.0"
version = "0.108.0"
description = "The official Python library for the kernel API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/kernel/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "kernel"
__version__ = "0.107.0" # x-release-please-version
__version__ = "0.108.0" # x-release-please-version
63 changes: 63 additions & 0 deletions src/kernel/lib/config_registry_wait.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import math
import time
import random
from datetime import datetime

from .._types import Omit, Headers
from .._exceptions import KernelError
from ..types.config_registry_response import ConfigRegistryResponse

DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL = 5.0
_TERMINAL_STATUSES = frozenset({"completed", "failed", "canceled", "expired"})


def validate_wait_options(poll_interval: float, max_wait_seconds: float | None) -> None:
if not math.isfinite(poll_interval) or poll_interval <= 0:
raise ValueError("Expected a finite, positive value for `poll_interval`")
if max_wait_seconds is not None and (not math.isfinite(max_wait_seconds) or max_wait_seconds < 0):
raise ValueError("Expected a finite, non-negative value for `max_wait_seconds`")


def poll_headers(extra_headers: Headers | None) -> Headers:
headers: dict[str, str | Omit] = dict(extra_headers or {})
headers["X-Stainless-Poll-Helper"] = "true"
return headers


def poll_delay(poll_interval: float) -> float:
return poll_interval * random.uniform(0.9, 1.1)


def wait_timeout_error(id: str, polls: int, last_status: str | None, started_at: float) -> TimeoutError:
elapsed = time.monotonic() - started_at
return TimeoutError(
f"Timed out waiting for config registry analysis {id!r} after {elapsed:.1f}s "
f"and {polls} polls; last status was {last_status!r}"
)


def analysis_finished(response: ConfigRegistryResponse, requested_id: str) -> tuple[bool, str]:
analysis = response.analysis
if analysis is None:
raise KernelError(f"Config registry response for {requested_id!r} is missing an analysis")

analysis_id = getattr(analysis, "id", None)
if not isinstance(analysis_id, str) or not analysis_id:
raise KernelError(f"Config registry response for {requested_id!r} has no valid analysis ID")
if analysis_id != requested_id:
raise KernelError(f"Config registry response for {requested_id!r} returned analysis {analysis_id!r}")

status = getattr(analysis, "status", None)
if not isinstance(status, str) or not status:
raise KernelError(f"Config registry analysis {requested_id!r} has no valid status")

if "finished_at" not in analysis.model_fields_set:
raise KernelError(f"Config registry analysis {requested_id!r} is missing `finished_at`")

finished_at = getattr(analysis, "finished_at", None)
if finished_at is not None and not isinstance(finished_at, datetime):
raise KernelError(f"Config registry analysis {requested_id!r} has an invalid `finished_at`")

return finished_at is not None or status in _TERMINAL_STATUSES, status
107 changes: 107 additions & 0 deletions src/kernel/resources/config_registry/analyses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import time

import httpx

from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
Expand All @@ -18,6 +20,14 @@
from ..._base_client import AsyncPaginator, make_request_options
from ...types.config_registry import analysis_list_params
from ...types.analysis_summary import AnalysisSummary
from ...lib.config_registry_wait import (
DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL,
poll_delay,
poll_headers,
analysis_finished,
wait_timeout_error,
validate_wait_options,
)
from ...types.config_registry_response import ConfigRegistryResponse

__all__ = ["AnalysesResource", "AsyncAnalysesResource"]
Expand Down Expand Up @@ -79,6 +89,54 @@ def retrieve(
cast_to=ConfigRegistryResponse,
)

def wait_for_result(
self,
id: str,
*,
poll_interval: float = DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL,
max_wait_seconds: float | None = None,
extra_headers: Headers | None = None,
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ConfigRegistryResponse:
"""Wait for an analysis to finish and return its complete result.

The first retrieval happens immediately. ``max_wait_seconds`` is a soft
polling deadline: an in-flight request and its normal retries may finish
after it. Timing out does not cancel the remote analysis.
"""
validate_wait_options(poll_interval, max_wait_seconds)
started_at = time.monotonic()
deadline = started_at + max_wait_seconds if max_wait_seconds is not None else None
headers = poll_headers(extra_headers)
polls = 0
last_status: str | None = None

while True:
if polls > 0 and deadline is not None and time.monotonic() >= deadline:
raise wait_timeout_error(id, polls, last_status, started_at)

response = self.retrieve(
id,
extra_headers=headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
)
polls += 1
finished, last_status = analysis_finished(response, id)
if finished:
return response

delay = poll_delay(poll_interval)
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise wait_timeout_error(id, polls, last_status, started_at)
delay = min(delay, remaining)
self._sleep(delay)

def list(
self,
*,
Expand Down Expand Up @@ -220,6 +278,55 @@ async def retrieve(
cast_to=ConfigRegistryResponse,
)

async def wait_for_result(
self,
id: str,
*,
poll_interval: float = DEFAULT_CONFIG_REGISTRY_POLL_INTERVAL,
max_wait_seconds: float | None = None,
extra_headers: Headers | None = None,
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ConfigRegistryResponse:
"""Wait for an analysis to finish and return its complete result.

The first retrieval happens immediately. ``max_wait_seconds`` is a soft
polling deadline: an in-flight request and its normal retries may finish
after it. Timing out does not cancel the remote analysis. Cancelling the
calling task stops the wait without cancelling the remote analysis.
"""
validate_wait_options(poll_interval, max_wait_seconds)
started_at = time.monotonic()
deadline = started_at + max_wait_seconds if max_wait_seconds is not None else None
headers = poll_headers(extra_headers)
polls = 0
last_status: str | None = None

while True:
if polls > 0 and deadline is not None and time.monotonic() >= deadline:
raise wait_timeout_error(id, polls, last_status, started_at)

response = await self.retrieve(
id,
extra_headers=headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
)
polls += 1
finished, last_status = analysis_finished(response, id)
if finished:
return response

delay = poll_delay(poll_interval)
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise wait_timeout_error(id, polls, last_status, started_at)
delay = min(delay, remaining)
await self._sleep(delay)

def list(
self,
*,
Expand Down
Loading
Loading