Skip to content

Commit ea91605

Browse files
committed
feat: add CancellationToken support to polling infrastructure
- Create CancellationToken class with sync/async event support - Update poll_until() and async_poll_until() with cancellation_token parameter - Add cancellable sleep using threading.Event.wait() and asyncio.wait_for() - Update PollingRequestOptions TypedDict with cancellation_token field - Propagate cancellation_token through Blueprint and ScenarioRun polling methods Part of porting TypeScript PR #765 features to Python SDK.
1 parent d9ef583 commit ea91605

6 files changed

Lines changed: 197 additions & 6 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Cancellation support for polling operations."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import threading
7+
from typing import TYPE_CHECKING
8+
9+
from .._exceptions import RunloopError
10+
11+
if TYPE_CHECKING:
12+
pass
13+
14+
__all__ = ["PollingCancelled", "CancellationToken"]
15+
16+
17+
class PollingCancelled(RunloopError):
18+
"""Exception raised when a polling operation is cancelled."""
19+
20+
pass
21+
22+
23+
class CancellationToken:
24+
"""Thread-safe cancellation token for polling operations.
25+
26+
Similar to JavaScript's AbortSignal. Works in both sync and async contexts.
27+
28+
Example (sync):
29+
>>> token = CancellationToken()
30+
>>> # In another thread:
31+
>>> token.cancel()
32+
>>> # In polling code:
33+
>>> token.raise_if_cancelled() # Raises PollingCancelled
34+
35+
Example (async):
36+
>>> token = CancellationToken()
37+
>>> # In another task:
38+
>>> token.cancel()
39+
>>> # In async polling code:
40+
>>> await asyncio.wait_for(token.async_event.wait(), timeout=1.0)
41+
"""
42+
43+
def __init__(self) -> None:
44+
"""Create a new cancellation token."""
45+
self._cancelled = False
46+
self._sync_event = threading.Event()
47+
self._async_event: asyncio.Event | None = None
48+
self._lock = threading.Lock()
49+
50+
def cancel(self) -> None:
51+
"""Mark this token as cancelled.
52+
53+
Thread-safe and can be called multiple times. Sets both sync and async events.
54+
"""
55+
with self._lock:
56+
if self._cancelled:
57+
return
58+
self._cancelled = True
59+
self._sync_event.set()
60+
if self._async_event is not None:
61+
self._async_event.set()
62+
63+
def is_cancelled(self) -> bool:
64+
"""Check if this token has been cancelled.
65+
66+
Returns:
67+
True if cancel() has been called, False otherwise.
68+
"""
69+
return self._cancelled
70+
71+
def raise_if_cancelled(self) -> None:
72+
"""Raise PollingCancelled if this token has been cancelled.
73+
74+
Raises:
75+
PollingCancelled: If cancel() has been called.
76+
"""
77+
if self._cancelled:
78+
raise PollingCancelled("Polling operation was cancelled")
79+
80+
@property
81+
def sync_event(self) -> threading.Event:
82+
"""Get the synchronous event for cancellation checking.
83+
84+
Returns:
85+
threading.Event that is set when cancel() is called.
86+
"""
87+
return self._sync_event
88+
89+
@property
90+
def async_event(self) -> asyncio.Event:
91+
"""Get the asynchronous event for cancellation checking.
92+
93+
Lazily creates the async event on first access. If cancel() was already called,
94+
the event will be set immediately.
95+
96+
Returns:
97+
asyncio.Event that is set when cancel() is called.
98+
"""
99+
if self._async_event is None:
100+
self._async_event = asyncio.Event()
101+
if self._cancelled:
102+
self._async_event.set()
103+
return self._async_event

src/runloop_api_client/lib/polling.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from typing import Any, TypeVar, Callable, Optional
33
from dataclasses import dataclass
44

5+
from .cancellation import CancellationToken
6+
57
T = TypeVar("T")
68

79

@@ -27,6 +29,7 @@ def poll_until(
2729
is_terminal: Callable[[T], bool],
2830
config: Optional[PollingConfig] = None,
2931
on_error: Optional[Callable[[Exception], T]] = None,
32+
cancellation_token: Optional[CancellationToken] = None,
3033
) -> T:
3134
"""
3235
Poll until a condition is met or timeout/max attempts are reached.
@@ -37,12 +40,14 @@ def poll_until(
3740
config: Optional polling configuration
3841
on_error: Optional error handler that can return a value to continue polling
3942
or re-raise the exception to stop polling
43+
cancellation_token: Optional token to cancel the polling operation
4044
4145
Returns:
4246
The final state of the polled object
4347
4448
Raises:
4549
PollingTimeout: When max attempts or timeout is reached
50+
PollingCancelled: If cancellation_token.cancel() is called
4651
"""
4752
if config is None:
4853
config = PollingConfig()
@@ -52,6 +57,10 @@ def poll_until(
5257
last_result = None
5358

5459
while True:
60+
# Check for cancellation before each iteration
61+
if cancellation_token is not None:
62+
cancellation_token.raise_if_cancelled()
63+
5564
try:
5665
last_result = retriever()
5766
except Exception as e:
@@ -72,4 +81,9 @@ def poll_until(
7281
if elapsed >= config.timeout_seconds:
7382
raise PollingTimeout(f"Exceeded timeout of {config.timeout_seconds} seconds", last_result)
7483

75-
time.sleep(config.interval_seconds)
84+
# Cancellable sleep
85+
if cancellation_token is not None:
86+
if cancellation_token.sync_event.wait(timeout=config.interval_seconds):
87+
cancellation_token.raise_if_cancelled()
88+
else:
89+
time.sleep(config.interval_seconds)

src/runloop_api_client/lib/polling_async.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Union, TypeVar, Callable, Optional, Awaitable
44

55
from .polling import PollingConfig, PollingTimeout
6+
from .cancellation import CancellationToken
67

78
T = TypeVar("T")
89

@@ -12,6 +13,7 @@ async def async_poll_until(
1213
is_terminal: Callable[[T], bool],
1314
config: Optional[PollingConfig] = None,
1415
on_error: Optional[Callable[[Exception], T]] = None,
16+
cancellation_token: Optional[CancellationToken] = None,
1517
) -> T:
1618
"""
1719
Poll until a condition is met or timeout/max attempts are reached.
@@ -22,12 +24,14 @@ async def async_poll_until(
2224
config: Optional polling configuration
2325
on_error: Optional error handler that can return a value to continue polling
2426
or re-raise the exception to stop polling
27+
cancellation_token: Optional token to cancel the polling operation
2528
2629
Returns:
2730
The final state of the polled object
2831
2932
Raises:
3033
PollingTimeout: When max attempts or timeout is reached
34+
PollingCancelled: If cancellation_token.cancel() is called
3135
"""
3236
if config is None:
3337
config = PollingConfig()
@@ -37,6 +41,10 @@ async def async_poll_until(
3741
last_result: Union[T, None] = None
3842

3943
while True:
44+
# Check for cancellation before each iteration
45+
if cancellation_token is not None:
46+
cancellation_token.raise_if_cancelled()
47+
4048
try:
4149
last_result = await retriever()
4250
except Exception as e:
@@ -57,4 +65,15 @@ async def async_poll_until(
5765
if elapsed >= config.timeout_seconds:
5866
raise PollingTimeout(f"Exceeded timeout of {config.timeout_seconds} seconds", last_result)
5967

60-
await asyncio.sleep(config.interval_seconds)
68+
# Cancellable async sleep
69+
if cancellation_token is not None:
70+
try:
71+
await asyncio.wait_for(
72+
cancellation_token.async_event.wait(),
73+
timeout=config.interval_seconds,
74+
)
75+
cancellation_token.raise_if_cancelled()
76+
except asyncio.TimeoutError:
77+
pass # Normal sleep completion
78+
else:
79+
await asyncio.sleep(config.interval_seconds)

src/runloop_api_client/resources/blueprints.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ..lib.polling import PollingConfig, poll_until
3030
from .._base_client import AsyncPaginator, make_request_options
3131
from ..lib.polling_async import async_poll_until
32+
from ..lib.cancellation import CancellationToken
3233
from .._utils._validation import ValidationNotification
3334
from ..types.blueprint_view import BlueprintView
3435
from ..types.blueprint_preview_view import BlueprintPreviewView
@@ -41,6 +42,7 @@
4142
# Type for request arguments that combine polling config with additional request options
4243
class BlueprintRequestArgs(TypedDict, total=False):
4344
polling_config: PollingConfig | None
45+
cancellation_token: CancellationToken | None
4446
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
4547
# The extra values given here take precedence over values defined on the client or passed to this method.
4648
extra_headers: Headers | None
@@ -280,6 +282,7 @@ def await_build_complete(
280282
id: str,
281283
*,
282284
polling_config: PollingConfig | None = None,
285+
cancellation_token: CancellationToken | None = None,
283286
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
284287
# The extra values given here take precedence over values defined on the client or passed to this method.
285288
extra_headers: Headers | None = None,
@@ -292,6 +295,7 @@ def await_build_complete(
292295
Args:
293296
id: The ID of the blueprint to wait for
294297
polling_config: Optional polling configuration
298+
cancellation_token: Token to cancel the wait operation
295299
extra_headers: Send extra headers
296300
extra_query: Add additional query parameters to the request
297301
extra_body: Add additional JSON properties to the request
@@ -302,6 +306,7 @@ def await_build_complete(
302306
303307
Raises:
304308
PollingTimeout: If polling times out before blueprint is built
309+
PollingCancelled: If cancellation_token.cancel() is called
305310
RunloopError: If blueprint enters a non-built terminal state
306311
"""
307312

@@ -313,7 +318,12 @@ def retrieve_blueprint() -> BlueprintView:
313318
def is_done_building(blueprint: BlueprintView) -> bool:
314319
return blueprint.status not in ["queued", "building", "provisioning"]
315320

316-
blueprint = poll_until(retrieve_blueprint, is_done_building, polling_config)
321+
blueprint = poll_until(
322+
retrieve_blueprint,
323+
is_done_building,
324+
polling_config,
325+
cancellation_token=cancellation_token,
326+
)
317327

318328
if blueprint.status != "build_complete":
319329
raise RunloopError(f"Blueprint entered non-built terminal state: {blueprint.status}")
@@ -338,6 +348,7 @@ def create_and_await_build_complete(
338348
services: Optional[Iterable[blueprint_create_params.Service]] | Omit = omit,
339349
system_setup_commands: Optional[SequenceNotStr[str]] | Omit = omit,
340350
polling_config: PollingConfig | None = None,
351+
cancellation_token: CancellationToken | None = None,
341352
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
342353
# The extra values given here take precedence over values defined on the client or passed to this method.
343354
extra_headers: Headers | None = None,
@@ -353,12 +364,14 @@ def create_and_await_build_complete(
353364
Args:
354365
See the `create` method for detailed documentation.
355366
polling_config: Optional polling configuration
367+
cancellation_token: Token to cancel the wait operation
356368
357369
Returns:
358370
The built blueprint
359371
360372
Raises:
361373
PollingTimeout: If polling times out before blueprint is built
374+
PollingCancelled: If cancellation_token.cancel() is called
362375
RunloopError: If blueprint enters a non-built terminal state
363376
"""
364377
# Pass all create_args to the underlying create method
@@ -387,6 +400,7 @@ def create_and_await_build_complete(
387400
return self.await_build_complete(
388401
blueprint.id,
389402
polling_config=polling_config,
403+
cancellation_token=cancellation_token,
390404
extra_headers=extra_headers,
391405
extra_query=extra_query,
392406
extra_body=extra_body,
@@ -960,6 +974,7 @@ async def await_build_complete(
960974
id: str,
961975
*,
962976
polling_config: PollingConfig | None = None,
977+
cancellation_token: CancellationToken | None = None,
963978
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
964979
# The extra values given here take precedence over values defined on the client or passed to this method.
965980
extra_headers: Headers | None = None,
@@ -972,6 +987,7 @@ async def await_build_complete(
972987
Args:
973988
id: The ID of the blueprint to wait for
974989
polling_config: Optional polling configuration
990+
cancellation_token: Token to cancel the wait operation
975991
extra_headers: Send extra headers
976992
extra_query: Add additional query parameters to the request
977993
extra_body: Add additional JSON properties to the request
@@ -982,6 +998,7 @@ async def await_build_complete(
982998
983999
Raises:
9841000
PollingTimeout: If polling times out before blueprint is built
1001+
PollingCancelled: If cancellation_token.cancel() is called
9851002
RunloopError: If blueprint enters a non-built terminal state
9861003
"""
9871004

@@ -993,7 +1010,12 @@ async def retrieve_blueprint() -> BlueprintView:
9931010
def is_done_building(blueprint: BlueprintView) -> bool:
9941011
return blueprint.status not in ["queued", "building", "provisioning"]
9951012

996-
blueprint = await async_poll_until(retrieve_blueprint, is_done_building, polling_config)
1013+
blueprint = await async_poll_until(
1014+
retrieve_blueprint,
1015+
is_done_building,
1016+
polling_config,
1017+
cancellation_token=cancellation_token,
1018+
)
9971019

9981020
if blueprint.status != "build_complete":
9991021
raise RunloopError(f"Blueprint entered non-built terminal state: {blueprint.status}")
@@ -1018,6 +1040,7 @@ async def create_and_await_build_complete(
10181040
services: Optional[Iterable[blueprint_create_params.Service]] | Omit = omit,
10191041
system_setup_commands: Optional[SequenceNotStr[str]] | Omit = omit,
10201042
polling_config: PollingConfig | None = None,
1043+
cancellation_token: CancellationToken | None = None,
10211044
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
10221045
# The extra values given here take precedence over values defined on the client or passed to this method.
10231046
extra_headers: Headers | None = None,
@@ -1033,12 +1056,14 @@ async def create_and_await_build_complete(
10331056
Args:
10341057
See the `create` method for detailed documentation.
10351058
polling_config: Optional polling configuration
1059+
cancellation_token: Token to cancel the wait operation
10361060
10371061
Returns:
10381062
The built blueprint
10391063
10401064
Raises:
10411065
PollingTimeout: If polling times out before blueprint is built
1066+
PollingCancelled: If cancellation_token.cancel() is called
10421067
RunloopError: If blueprint enters a non-built terminal state
10431068
"""
10441069
# Pass all create_args to the underlying create method
@@ -1067,6 +1092,7 @@ async def create_and_await_build_complete(
10671092
return await self.await_build_complete(
10681093
blueprint.id,
10691094
polling_config=polling_config,
1095+
cancellation_token=cancellation_token,
10701096
extra_headers=extra_headers,
10711097
extra_query=extra_query,
10721098
extra_body=extra_body,

0 commit comments

Comments
 (0)