Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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 @@
{
".": "1.24.0"
".": "1.25.0"
}
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## 1.25.0 (2026-07-24)

Full Changelog: [v1.24.0...v1.25.0](https://github.com/runloopai/api-client-python/compare/v1.24.0...v1.25.0)

### Features

* **mux:** wait_for_eviction endpoint for flex eviction notifications ([#10325](https://github.com/runloopai/api-client-python/issues/10325)) ([c06794d](https://github.com/runloopai/api-client-python/commit/c06794d413f37be16ee5955fe31b07b92b8adbc8))


### Bug Fixes

* **mux:** field-level merge on network policy egress updates ([#10338](https://github.com/runloopai/api-client-python/issues/10338)) ([cfc97ef](https://github.com/runloopai/api-client-python/commit/cfc97ef55a65e88abb0d76f733cf51dc33823fb0))


### Chores

* **stainless:** generate watch_evictions as an SSE stream ([#10404](https://github.com/runloopai/api-client-python/issues/10404)) ([0413e7b](https://github.com/runloopai/api-client-python/commit/0413e7ba4cd10b919a3fc50e67e0238dc95f4b18))

## 1.24.0 (2026-07-22)

Full Changelog: [v1.23.3...v1.24.0](https://github.com/runloopai/api-client-python/compare/v1.23.3...v1.24.0)
Expand Down
2 changes: 1 addition & 1 deletion api.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ Methods:
- <code title="post /v1/devboxes/{id}/suspend">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">suspend</a>(id) -> <a href="./src/runloop_api_client/types/devbox_view.py">DevboxView</a></code>
- <code title="post /v1/devboxes/{id}/upload_file">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">upload_file</a>(id, \*\*<a href="src/runloop_api_client/types/devbox_upload_file_params.py">params</a>) -> object</code>
- <code title="post /v1/devboxes/{devbox_id}/executions/{execution_id}/wait_for_status">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">wait_for_command</a>(execution_id, \*, devbox_id, \*\*<a href="src/runloop_api_client/types/devbox_wait_for_command_params.py">params</a>) -> <a href="./src/runloop_api_client/types/devbox_async_execution_detail_view.py">DevboxAsyncExecutionDetailView</a></code>
- <code title="get /v1/devboxes/watch_evictions">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">watch_evictions</a>() -> <a href="./src/runloop_api_client/types/devbox_eviction_event_view.py">DevboxEvictionEventView</a></code>
- <code title="get /v1/devboxes/evictions/watch">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">watch_evictions</a>() -> <a href="./src/runloop_api_client/types/devbox_eviction_event_view.py">DevboxEvictionEventView</a></code>
- <code title="post /v1/devboxes/{id}/write_file_contents">client.devboxes.<a href="./src/runloop_api_client/resources/devboxes/devboxes.py">write_file_contents</a>(id, \*\*<a href="src/runloop_api_client/types/devbox_write_file_contents_params.py">params</a>) -> <a href="./src/runloop_api_client/types/devbox_execution_detail_view.py">DevboxExecutionDetailView</a></code>


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 = "runloop_api_client"
version = "1.24.0"
version = "1.25.0"
description = "The official Python library for the runloop API"
dynamic = ["readme"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/runloop_api_client/_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__ = "runloop_api_client"
__version__ = "1.24.0" # x-release-please-version
__version__ = "1.25.0" # x-release-please-version
4 changes: 2 additions & 2 deletions src/runloop_api_client/resources/devboxes/devboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1762,7 +1762,7 @@ def watch_evictions(
and delivery is not guaranteed.
"""
return self._get(
"/v1/devboxes/watch_evictions",
"/v1/devboxes/evictions/watch",
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
Expand Down Expand Up @@ -3464,7 +3464,7 @@ async def watch_evictions(
and delivery is not guaranteed.
"""
return await self._get(
"/v1/devboxes/watch_evictions",
"/v1/devboxes/evictions/watch",
options=make_request_options(
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
),
Expand Down
2 changes: 2 additions & 0 deletions src/runloop_api_client/sdk/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .async_devbox import AsyncDevbox
from .async_scorer import AsyncScorer
from .async_secret import AsyncSecret
from .async_eviction import shutdown_monitor_for as shutdown_eviction_monitor
from .async_scenario import AsyncScenario
from .async_snapshot import AsyncSnapshot
from .async_benchmark import AsyncBenchmark
Expand Down Expand Up @@ -1373,6 +1374,7 @@ def __init__(

async def aclose(self) -> None:
"""Close the underlying HTTP client and release resources."""
await shutdown_eviction_monitor(self.api)
await self.api.close()

async def __aenter__(self) -> "AsyncRunloopSDK":
Expand Down
20 changes: 20 additions & 0 deletions src/runloop_api_client/sdk/async_devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from ._helpers import filter_params
from .._streaming import AsyncStream
from ..lib.polling import PollingConfig
from .async_eviction import AsyncEvictionCallback, monitor_for as _eviction_monitor_for
from ..types.devboxes import ExecutionUpdateChunk
from .async_execution import AsyncExecution, _AsyncStreamingGroup
from .async_execution_result import AsyncExecutionResult
Expand Down Expand Up @@ -216,6 +217,25 @@ async def await_suspended(self, *, polling_config: PollingConfig | None = None)
"""
return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config)

async def on_evict(self, callback: AsyncEvictionCallback) -> None:
"""Register a callback fired once if this devbox gets a pending infrastructure eviction.

The first ``on_evict`` across any devbox on this client opens a single
account-wide notification stream; it closes automatically once every
registered devbox has been notified. The callback runs at most once and is
invoked as ``callback(devbox, eviction_deadline_ms)`` — the devbox itself and
the Unix millisecond deadline by which it will be suspended. Use it to run
cleanup before the devbox is suspended. The callback may be sync or async.

:param callback: Callable invoked with this devbox and its eviction deadline (ms).
:type callback: AsyncEvictionCallback
"""
await _eviction_monitor_for(self._client).register(self, callback)

async def cancel_on_evict(self) -> None:
"""Withdraw this devbox's eviction interest registered via :meth:`on_evict`."""
await _eviction_monitor_for(self._client).unregister(self._id)

async def shutdown(
self,
**options: Unpack[LongRequestOptions],
Expand Down
119 changes: 119 additions & 0 deletions src/runloop_api_client/sdk/async_eviction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Async client-side eviction notification monitor.

Async counterpart of :mod:`runloop_api_client.sdk.eviction`; see that module for the
delivery contract. Callbacks may be plain functions or coroutines.
"""

from __future__ import annotations

import asyncio
import inspect
import logging
from typing import TYPE_CHECKING, Dict, Tuple, Union, Callable, Optional, Awaitable
from weakref import WeakKeyDictionary

if TYPE_CHECKING:
from ..types import DevboxEvictionEventView
from .._client import AsyncRunloop
from .._streaming import AsyncStream
from .async_devbox import AsyncDevbox

AsyncEvictionCallback = Callable[["AsyncDevbox", int], Union[None, Awaitable[None]]]
"""Sync or async callable invoked once with the devbox and its eviction deadline (ms)."""

_logger = logging.getLogger(__name__)


class AsyncEvictionMonitor:
"""Async fan-out of account-wide eviction notifications to per-devbox callbacks."""

def __init__(self, client: "AsyncRunloop") -> None:
self._client = client
self._lock = asyncio.Lock()
self._callbacks: Dict[str, Tuple["AsyncDevbox", AsyncEvictionCallback]] = {}
self._task: Optional["asyncio.Task[None]"] = None
self._stream: Optional["AsyncStream[DevboxEvictionEventView]"] = None

async def register(self, devbox: "AsyncDevbox", callback: AsyncEvictionCallback) -> None:
"""Add ``devbox`` to the interest set, starting the stream task if idle."""
async with self._lock:
self._callbacks[devbox.id] = (devbox, callback)
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run(), name="runloop-eviction-monitor")

async def unregister(self, devbox_id: str) -> None:
"""Drop ``devbox_id``; close the stream if it was the last interested devbox."""
async with self._lock:
self._callbacks.pop(devbox_id, None)
empty = not self._callbacks
if empty:
await self._close_stream()

async def close(self) -> None:
"""Clear all interest and tear down the stream."""
async with self._lock:
self._callbacks.clear()
await self._close_stream()

async def _run(self) -> None:
try:
stream = await self._client.devboxes.watch_evictions()
async with self._lock:
self._stream = stream
async with stream:
async for event in stream:
await self._dispatch(event)
async with self._lock:
if not self._callbacks:
break
except asyncio.CancelledError:
raise
except Exception:
_logger.exception("async eviction monitor stream failed")
finally:
async with self._lock:
self._stream = None
self._task = None

async def _dispatch(self, event: "DevboxEvictionEventView") -> None:
async with self._lock:
entry = self._callbacks.pop(event.devbox_id, None)
if entry is None:
return
devbox, callback = entry
try:
result = callback(devbox, event.eviction_deadline_ms)
if inspect.isawaitable(result):
await result
except Exception:
_logger.exception("error in eviction callback for devbox %s", event.devbox_id)

async def _close_stream(self) -> None:
async with self._lock:
stream = self._stream
self._stream = None
if stream is not None:
try:
await stream.close()
except Exception:
_logger.debug("error closing eviction stream", exc_info=True)


# asyncio is single-threaded, so the registry needs no lock.
_monitors: "WeakKeyDictionary[AsyncRunloop, AsyncEvictionMonitor]" = WeakKeyDictionary()


def monitor_for(client: "AsyncRunloop") -> AsyncEvictionMonitor:
"""Return the shared :class:`AsyncEvictionMonitor` for ``client``, creating it once."""
monitor = _monitors.get(client)
if monitor is None:
monitor = AsyncEvictionMonitor(client)
_monitors[client] = monitor
return monitor


async def shutdown_monitor_for(client: "AsyncRunloop") -> None:
"""Tear down the shared monitor for ``client`` if one exists."""
monitor = _monitors.pop(client, None)
if monitor is not None:
await monitor.close()
20 changes: 20 additions & 0 deletions src/runloop_api_client/sdk/devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .._types import omit
from .._client import Runloop
from ._helpers import filter_params
from .eviction import EvictionCallback, monitor_for as _eviction_monitor_for
from .execution import Execution, _StreamingGroup
from .._streaming import Stream
from ..lib.polling import PollingConfig
Expand Down Expand Up @@ -219,6 +220,25 @@ def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Dev
"""
return self._client.devboxes.await_suspended(self._id, polling_config=polling_config)

def on_evict(self, callback: EvictionCallback) -> None:
"""Register a callback fired once if this devbox gets a pending infrastructure eviction.

The first ``on_evict`` across any devbox on this client opens a single
account-wide notification stream; it closes automatically once every
registered devbox has been notified. The callback runs at most once and is
invoked as ``callback(devbox, eviction_deadline_ms)`` — the devbox itself and
the Unix millisecond deadline by which it will be suspended. Use it to run
cleanup before the devbox is suspended.

:param callback: Callable invoked with this devbox and its eviction deadline (ms).
:type callback: EvictionCallback
"""
_eviction_monitor_for(self._client).register(self, callback)

def cancel_on_evict(self) -> None:
"""Withdraw this devbox's eviction interest registered via :meth:`on_evict`."""
_eviction_monitor_for(self._client).unregister(self._id)

def shutdown(
self,
**options: Unpack[LongRequestOptions],
Expand Down
Loading
Loading