Skip to content

Commit 47b862c

Browse files
feat: add sdk.deno.deploy() (#36)
1 parent 38a2384 commit 47b862c

File tree

3 files changed

+383
-5
lines changed

3 files changed

+383
-5
lines changed

src/deno_sandbox/revisions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class RevisionWithoutTimelines(TypedDict):
1818
id: str
1919
"""The unique identifier for the revision."""
2020

21-
status: Literal["building", "ready", "error"]
21+
status: Literal["building", "ready", "error", "routed"]
2222
"""The status of the revision."""
2323

2424
created_at: str

src/deno_sandbox/sandbox.py

Lines changed: 266 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
cast,
1717
)
1818
from typing_extensions import Literal, NotRequired, TypeAlias
19+
import httpx
1920

2021
from .stream import Streamable, complete_stream, start_stream
21-
22+
from .utils import convert_to_snake_case
2223
from .env import AsyncSandboxEnv, SandboxEnv
2324
from .fs import AsyncSandboxFs, SandboxFs
2425
from .process import (
@@ -43,6 +44,7 @@
4344
from .transport import (
4445
WebSocketTransport,
4546
)
47+
from .revisions import Revision
4648

4749

4850
Mode: TypeAlias = Literal["connect", "create"]
@@ -96,6 +98,48 @@ class ExposeHTTPResult(TypedDict):
9698
domain: str
9799

98100

101+
class DeployBuildOptions(TypedDict, total=False):
102+
"""Build configuration for deployment."""
103+
104+
mode: Literal["none"]
105+
"""The build mode to use. Currently only 'none' is supported. Defaults to 'none'."""
106+
107+
entrypoint: str
108+
"""The entrypoint file path relative to the path option. Defaults to 'main.ts'."""
109+
110+
args: builtins.list[str]
111+
"""Arguments to pass to the entrypoint script."""
112+
113+
114+
class DeployOptions(TypedDict, total=False):
115+
"""Options for deploying an app using deno.deploy()."""
116+
117+
path: str
118+
"""The path to the directory to deploy. If relative, it is relative to /app. Defaults to '.'."""
119+
120+
production: bool
121+
"""Whether to deploy in production mode. Defaults to True."""
122+
123+
preview: bool
124+
"""Whether to deploy a preview deployment. Defaults to False."""
125+
126+
build: DeployBuildOptions
127+
"""Build options to use."""
128+
129+
130+
class BuildLog(TypedDict):
131+
"""A build log entry from app deployment."""
132+
133+
timestamp: str
134+
"""The timestamp of the build log."""
135+
136+
level: Literal["info", "error"]
137+
"""The level of the build log."""
138+
139+
message: str
140+
"""The message of the build log."""
141+
142+
99143
class AsyncSandboxApi:
100144
def __init__(
101145
self,
@@ -352,14 +396,125 @@ def list(
352396
return PaginatedList(self._bridge, paginated)
353397

354398

399+
async def _parse_sse_stream(stream: AsyncIterator[bytes]) -> AsyncIterator[str]:
400+
"""Parse Server-Sent Events from a byte stream."""
401+
buffer = b""
402+
async for chunk in stream:
403+
buffer += chunk
404+
while b"\n" in buffer:
405+
line, buffer = buffer.split(b"\n", 1)
406+
line = line.rstrip(b"\r")
407+
if line.startswith(b"data: "):
408+
data = line[6:].decode("utf-8")
409+
yield data
410+
411+
412+
class AsyncBuild:
413+
"""The result of a deno.deploy() operation."""
414+
415+
def __init__(
416+
self,
417+
revision_id: str,
418+
app: str,
419+
client: AsyncConsoleClient,
420+
):
421+
self.id = revision_id
422+
"""The ID of the build."""
423+
self._app = app
424+
self._client = client
425+
426+
async def wait(self) -> Revision:
427+
"""A coroutine that resolves when the build is complete, returning the revision."""
428+
429+
url = self._client._options["console_url"].join(
430+
f"/api/v2/apps/{self._app}/revisions/{self.id}/status"
431+
)
432+
headers = {
433+
"Authorization": f"Bearer {self._client._options['token']}",
434+
}
435+
436+
async with httpx.AsyncClient() as http_client:
437+
response = await http_client.get(str(url), headers=headers)
438+
response.raise_for_status()
439+
revision_data = response.json()
440+
441+
# Fetch timelines
442+
timelines_url = self._client._options["console_url"].join(
443+
f"/api/v2/apps/{self._app}/revisions/{self.id}/timelines"
444+
)
445+
timelines_response = await http_client.get(
446+
str(timelines_url), headers=headers
447+
)
448+
timelines_response.raise_for_status()
449+
timelines_data = timelines_response.json()
450+
451+
result = convert_to_snake_case(revision_data)
452+
result["timelines"] = convert_to_snake_case(timelines_data)
453+
return cast(Revision, result)
454+
455+
async def logs(self) -> AsyncIterator[BuildLog]:
456+
"""An async iterator of build logs."""
457+
url = self._client._options["console_url"].join(
458+
f"/api/v2/apps/{self._app}/revisions/{self.id}/logs"
459+
)
460+
headers = {
461+
"Authorization": f"Bearer {self._client._options['token']}",
462+
}
463+
464+
async with httpx.AsyncClient() as http_client:
465+
async with http_client.stream("GET", str(url), headers=headers) as response:
466+
response.raise_for_status()
467+
async for data in _parse_sse_stream(response.aiter_bytes()):
468+
try:
469+
yield cast(BuildLog, json.loads(data))
470+
except json.JSONDecodeError:
471+
# Skip malformed log entries
472+
continue
473+
474+
475+
class Build:
476+
"""The result of a deno.deploy() operation (sync version)."""
477+
478+
def __init__(
479+
self,
480+
revision_id: str,
481+
app: str,
482+
client: AsyncConsoleClient,
483+
bridge: AsyncBridge,
484+
):
485+
self.id = revision_id
486+
"""The ID of the build."""
487+
self._async_build = AsyncBuild(revision_id, app, client)
488+
self._bridge = bridge
489+
490+
def wait(self) -> Revision:
491+
"""Returns the revision when the build is complete."""
492+
return self._bridge.run(self._async_build.wait())
493+
494+
def logs(self) -> builtins.list[BuildLog]:
495+
"""Returns a list of build logs."""
496+
497+
async def _collect_logs():
498+
logs = []
499+
async for log in self._async_build.logs():
500+
logs.append(log)
501+
return logs
502+
503+
return self._bridge.run(_collect_logs())
504+
505+
355506
class AsyncSandboxDeno:
356507
def __init__(
357508
self,
358509
rpc: AsyncRpcClient,
359510
processes: builtins.list[AsyncChildProcess],
511+
client: AsyncConsoleClient,
512+
sandbox_id: str,
360513
):
361514
self._rpc = rpc
362515
self._processes = processes
516+
self._client = client
517+
self._sandbox_id = sandbox_id
363518

364519
async def run(
365520
self,
@@ -520,18 +675,92 @@ async def repl(
520675
self._processes.append(process)
521676
return process
522677

678+
async def deploy(
679+
self, app: str, *, options: Optional[DeployOptions] = None
680+
) -> AsyncBuild:
681+
"""Deploy the contents of the sandbox to the specified app in Deno Deploy platform.
682+
683+
Args:
684+
app: The app ID or slug to deploy to.
685+
options: Deployment configuration options.
686+
687+
Returns:
688+
An AsyncBuild object with the revision ID and methods to check status and logs.
689+
690+
Example:
691+
```python
692+
from deno_sandbox import AsyncDenoDeploy
693+
694+
async with AsyncDenoDeploy() as client:
695+
async with client.sandbox.create() as sandbox:
696+
await sandbox.fs.write_text_file(
697+
"main.ts",
698+
'Deno.serve(() => new Response("Hi from sandbox.deploy()"))',
699+
)
700+
build = await sandbox.deno.deploy("my-deno-app", options={
701+
"build": {"entrypoint": "main.ts"}
702+
})
703+
print(f"Deployed revision ID: {build.id}")
704+
revision = await build.done
705+
print(f"Revision status: {revision['status']}")
706+
```
707+
"""
708+
url = self._client._options["console_url"].join(f"/api/v2/apps/{app}/deploy")
709+
headers = {
710+
"Content-Type": "application/json",
711+
"Authorization": f"Bearer {self._client._options['token']}",
712+
}
713+
714+
# Build request body
715+
body: dict[str, Any] = {
716+
"entrypoint": (
717+
options.get("build", {}).get("entrypoint", "main.ts")
718+
if options
719+
else "main.ts"
720+
),
721+
"sandboxId": self._sandbox_id,
722+
}
723+
724+
if options:
725+
if "build" in options and "args" in options["build"]:
726+
body["args"] = options["build"]["args"]
727+
if "production" in options:
728+
body["production"] = options["production"]
729+
if "preview" in options:
730+
body["preview"] = options["preview"]
731+
if "path" in options:
732+
body["path"] = options["path"]
733+
else:
734+
body["path"] = "/app"
735+
else:
736+
body["path"] = "/app"
737+
738+
# Make the deploy request
739+
async with httpx.AsyncClient() as http_client:
740+
response = await http_client.post(
741+
str(url), headers=headers, json=body, timeout=30.0
742+
)
743+
response.raise_for_status()
744+
result = response.json()
745+
revision_id = result["revisionId"]
746+
747+
return AsyncBuild(revision_id, app, self._client)
748+
523749

524750
class SandboxDeno:
525751
def __init__(
526752
self,
527753
rpc: AsyncRpcClient,
528754
bridge: AsyncBridge,
529755
processes: builtins.list[AsyncChildProcess],
756+
client: AsyncConsoleClient,
757+
sandbox_id: str,
530758
):
531759
self._rpc = rpc
532760
self._bridge = bridge
761+
self._client = client
533762

534-
self._async = AsyncSandboxDeno(rpc, processes)
763+
self._async = AsyncSandboxDeno(rpc, processes, client, sandbox_id)
535764

536765
def run(
537766
self,
@@ -632,6 +861,37 @@ def repl(
632861
)
633862
return DenoRepl(self._rpc, self._bridge, async_repl)
634863

864+
def deploy(self, app: str, *, options: Optional[DeployOptions] = None) -> Build:
865+
"""Deploy the contents of the sandbox to the specified app in Deno Deploy platform.
866+
867+
Args:
868+
app: The app ID or slug to deploy to.
869+
options: Deployment configuration options.
870+
871+
Returns:
872+
A Build object with the revision ID and methods to check status and logs.
873+
874+
Example:
875+
```python
876+
from deno_sandbox import DenoDeploy
877+
878+
client = DenoDeploy()
879+
with client.sandbox.create() as sandbox:
880+
sandbox.fs.write_text_file(
881+
"main.ts",
882+
'Deno.serve(() => new Response("Hi from sandbox.deploy()"))',
883+
)
884+
build = sandbox.deno.deploy("my-deno-app", options={
885+
"build": {"entrypoint": "main.ts"}
886+
})
887+
print(f"Deployed revision ID: {build.id}")
888+
revision = build.done
889+
print(f"Revision status: {revision['status']}")
890+
```
891+
"""
892+
async_build = self._bridge.run(self._async.deploy(app, options=options))
893+
return Build(async_build.id, app, self._client, self._bridge)
894+
635895

636896
class AsyncSandbox:
637897
def __init__(
@@ -645,7 +905,7 @@ def __init__(
645905
self.ssh: None = None
646906
self.id = sandbox_id
647907
self.fs = AsyncSandboxFs(rpc)
648-
self.deno = AsyncSandboxDeno(rpc, self._processes)
908+
self.deno = AsyncSandboxDeno(rpc, self._processes, client, sandbox_id)
649909
self.env = AsyncSandboxEnv(rpc)
650910

651911
@property
@@ -839,7 +1099,9 @@ def __init__(
8391099
self.ssh: None = None
8401100
self.id = async_sandbox.id
8411101
self.fs = SandboxFs(rpc, bridge)
842-
self.deno = SandboxDeno(rpc, bridge, self._async._processes)
1102+
self.deno = SandboxDeno(
1103+
rpc, bridge, self._async._processes, client, async_sandbox.id
1104+
)
8431105
self.env = SandboxEnv(rpc, bridge)
8441106

8451107
@property

0 commit comments

Comments
 (0)