Skip to content

Commit 5cc0535

Browse files
feat: add support for revision progress (#56)
1 parent 9f8ed1a commit 5cc0535

File tree

4 files changed

+219
-1
lines changed

4 files changed

+219
-1
lines changed

src/deno_sandbox/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
AsyncRevisions,
2020
Revision,
2121
RevisionListItem,
22+
RevisionProgress,
23+
ProgressStage,
24+
ProgressStageStatus,
2225
FileAsset,
2326
SymlinkAsset,
2427
Asset,
@@ -58,6 +61,9 @@
5861
"RuntimeLogsResponse",
5962
"Revision",
6063
"RevisionListItem",
64+
"RevisionProgress",
65+
"ProgressStage",
66+
"ProgressStageStatus",
6167
"FileAsset",
6268
"SymlinkAsset",
6369
"Asset",

src/deno_sandbox/console.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3+
import json
34
from typing_extensions import NotRequired
45
from typing import (
56
Any,
7+
AsyncIterator,
68
Generic,
79
Literal,
810
Optional,
@@ -226,6 +228,43 @@ async def get_paginated(
226228

227229
return AsyncPaginatedList(self, items, path, next_cursor, params)
228230

231+
async def stream_ndjson(self, path: str) -> AsyncIterator[dict]:
232+
"""Stream NDJSON responses line by line.
233+
234+
Yields parsed JSON objects for each line in the response.
235+
"""
236+
req_url = self._options["console_url"].join(path)
237+
headers = {
238+
"Accept": "application/x-ndjson",
239+
}
240+
async with self.client.stream(
241+
"GET", req_url, headers=headers, timeout=None
242+
) as response:
243+
if not response.is_success:
244+
await response.aread()
245+
code = "UNKNOWN_ERROR"
246+
message = (
247+
f"Request to {req_url} failed with status {response.status_code}"
248+
)
249+
trace_id = response.headers.get("x-deno-trace-id")
250+
try:
251+
body = response.json()
252+
if (
253+
isinstance(body, dict)
254+
and isinstance(body.get("code"), str)
255+
and isinstance(body.get("message"), str)
256+
):
257+
code = body["code"]
258+
message = body["message"]
259+
except Exception:
260+
pass
261+
raise HTTPStatusError(response.status_code, message, code, trace_id)
262+
263+
async for line in response.aiter_lines():
264+
line = line.strip()
265+
if line:
266+
yield json.loads(line)
267+
229268
async def close(self) -> None:
230269
await self.client.aclose()
231270

src/deno_sandbox/revisions.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
from __future__ import annotations
22

33
import warnings
4-
from typing import Any, Dict, List, TypedDict, Union, cast, overload
4+
from typing import (
5+
Any,
6+
AsyncIterator,
7+
Dict,
8+
Iterator,
9+
List,
10+
TypedDict,
11+
Union,
12+
cast,
13+
overload,
14+
)
515
from typing_extensions import Literal, NotRequired, Optional
616

717
from deno_sandbox.apps import Config, EnvVar, LayerRef
@@ -89,6 +99,46 @@ class Revision(TypedDict):
8999
"""ISO 8601 timestamp of deletion, or null if active."""
90100

91101

102+
ProgressStageStatus = Literal[
103+
"pending",
104+
"running",
105+
"succeeded",
106+
"skipped",
107+
"failed",
108+
"timed_out",
109+
"cancelled",
110+
"errored",
111+
]
112+
113+
114+
class ProgressStage(TypedDict):
115+
status: ProgressStageStatus
116+
"""The current status of this stage."""
117+
118+
start: NotRequired[str | None]
119+
"""ISO 8601 timestamp when the stage started, or null."""
120+
121+
end: NotRequired[str | None]
122+
"""ISO 8601 timestamp when the stage ended, or null."""
123+
124+
125+
class RevisionProgress(TypedDict):
126+
queued: NotRequired[ProgressStage]
127+
"""Queue stage status."""
128+
129+
preparing: NotRequired[ProgressStage]
130+
"""Preparation stage status."""
131+
132+
installing: NotRequired[ProgressStage]
133+
"""Dependency installation stage status."""
134+
135+
building: NotRequired[ProgressStage]
136+
"""Build command execution stage status."""
137+
138+
deploying: NotRequired[ProgressStage]
139+
"""Artifact upload and routing stage status."""
140+
141+
92142
# Keep old name as alias for backward compatibility
93143
RevisionWithoutTimelines = RevisionListItem
94144

@@ -173,6 +223,21 @@ async def cancel(self, revision: str) -> Revision:
173223
result = await self._client.post(f"/api/v2/revisions/{revision}/cancel", {})
174224
return cast(Revision, convert_to_snake_case(result))
175225

226+
async def progress(self, revision: str) -> AsyncIterator[RevisionProgress]:
227+
"""Stream revision build progress.
228+
229+
Yields RevisionProgress events as the revision progresses through
230+
its build stages. The stream ends when the revision reaches a
231+
terminal state (succeeded, failed, or skipped).
232+
233+
Args:
234+
revision: The revision ID.
235+
"""
236+
async for event in self._client.stream_ndjson(
237+
f"/api/v2/revisions/{revision}/progress"
238+
):
239+
yield cast(RevisionProgress, convert_to_snake_case(event))
240+
176241
async def deploy(
177242
self,
178243
app: str,
@@ -268,6 +333,22 @@ def cancel(self, revision: str) -> Revision:
268333
"""
269334
return self._bridge.run(self._async.cancel(revision))
270335

336+
def progress(self, revision: str) -> Iterator[RevisionProgress]:
337+
"""Stream revision build progress.
338+
339+
Yields RevisionProgress events as the revision progresses through
340+
its build stages. The stream ends when the revision reaches a
341+
terminal state (succeeded, failed, or skipped).
342+
343+
Args:
344+
revision: The revision ID.
345+
"""
346+
347+
async def _collect() -> list[RevisionProgress]:
348+
return [event async for event in self._async.progress(revision)]
349+
350+
return iter(self._bridge.run(_collect()))
351+
271352
def deploy(
272353
self,
273354
app: str,

tests/test_revisions.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,95 @@ async def test_revisions_deploy_preview_only_async():
241241
assert len(preview) > 0, "should be on preview timeline"
242242
finally:
243243
await sdk.apps.delete(app["id"])
244+
245+
246+
VALID_STAGE_STATUSES = {
247+
"pending",
248+
"running",
249+
"succeeded",
250+
"skipped",
251+
"failed",
252+
"timed_out",
253+
"cancelled",
254+
"errored",
255+
}
256+
257+
258+
def _assert_progress_events(events: list[dict]) -> None:
259+
"""Shared assertions for progress event lists."""
260+
assert len(events) > 0, "Expected at least one progress event"
261+
262+
for event in events:
263+
assert isinstance(event, dict)
264+
# Each event should have at least one known stage key
265+
stage_keys = {"queued", "preparing", "installing", "building", "deploying"}
266+
found_keys = stage_keys & event.keys()
267+
assert len(found_keys) > 0, f"No known stage keys in event: {event}"
268+
269+
for key in found_keys:
270+
stage = event[key]
271+
assert "status" in stage, f"Stage {key} missing 'status'"
272+
assert stage["status"] in VALID_STAGE_STATUSES, (
273+
f"Stage {key} has unexpected status: {stage['status']}"
274+
)
275+
276+
# The last event should have at least one stage in a terminal state
277+
last = events[-1]
278+
terminal_statuses = {"succeeded", "failed", "skipped"}
279+
has_terminal = any(
280+
last.get(k, {}).get("status") in terminal_statuses
281+
for k in ("queued", "preparing", "installing", "building", "deploying")
282+
if k in last
283+
)
284+
assert has_terminal, f"Last event has no terminal stage: {last}"
285+
286+
287+
@pytest.mark.timeout(120)
288+
@pytest.mark.asyncio(loop_scope="session")
289+
async def test_revisions_progress_async():
290+
"""Deploy a revision and stream progress until terminal state."""
291+
sdk = AsyncDenoDeploy()
292+
app = await sdk.apps.create()
293+
try:
294+
revision = await sdk.revisions.deploy(
295+
app["id"],
296+
assets={
297+
"main.ts": {
298+
"kind": "file",
299+
"encoding": "utf-8",
300+
"content": 'Deno.serve(() => new Response("Hello"))',
301+
}
302+
},
303+
)
304+
305+
events = []
306+
async for event in sdk.revisions.progress(revision["id"]):
307+
events.append(event)
308+
309+
_assert_progress_events(events)
310+
finally:
311+
await sdk.apps.delete(app["id"])
312+
313+
314+
@pytest.mark.timeout(120)
315+
def test_revisions_progress_sync():
316+
"""Deploy a revision and stream progress until terminal state (sync)."""
317+
sdk = DenoDeploy()
318+
app = sdk.apps.create()
319+
try:
320+
revision = sdk.revisions.deploy(
321+
app["id"],
322+
assets={
323+
"main.ts": {
324+
"kind": "file",
325+
"encoding": "utf-8",
326+
"content": 'Deno.serve(() => new Response("Hello"))',
327+
}
328+
},
329+
)
330+
331+
events = list(sdk.revisions.progress(revision["id"]))
332+
333+
_assert_progress_events(events)
334+
finally:
335+
sdk.apps.delete(app["id"])

0 commit comments

Comments
 (0)