Skip to content

Commit 3d4ea01

Browse files
authored
Add tunnel helpers to OO sdk (#735)
1 parent d809100 commit 3d4ea01

File tree

4 files changed

+236
-0
lines changed

4 files changed

+236
-0
lines changed

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,50 @@ async def get_info(
119119
**options,
120120
)
121121

122+
async def get_tunnel(
123+
self,
124+
**options: Unpack[BaseRequestOptions],
125+
) -> TunnelView | None:
126+
"""Retrieve the V2 tunnel information for this devbox.
127+
128+
:param options: Optional request configuration
129+
:return: Tunnel details if a tunnel is enabled, None otherwise
130+
:rtype: TunnelView | None
131+
132+
Example:
133+
>>> tunnel = await devbox.get_tunnel()
134+
>>> if tunnel:
135+
... print(f"Tunnel key: {tunnel.tunnel_key}")
136+
"""
137+
info = await self.get_info(**options)
138+
return info.tunnel
139+
140+
async def get_tunnel_url(
141+
self,
142+
port: int,
143+
**options: Unpack[BaseRequestOptions],
144+
) -> str | None:
145+
"""Get the public tunnel URL for a specific port.
146+
147+
Constructs the tunnel URL using the format:
148+
``https://{port}-{tunnel_key}.tunnel.runloop.ai``
149+
150+
:param port: The port number to construct the URL for
151+
:type port: int
152+
:param options: Optional request configuration
153+
:return: The public tunnel URL if a tunnel is enabled, None otherwise
154+
:rtype: str | None
155+
156+
Example:
157+
>>> url = await devbox.get_tunnel_url(8080)
158+
>>> if url:
159+
... print(f"Access your service at: {url}")
160+
"""
161+
tunnel_view = await self.get_tunnel(**options)
162+
if tunnel_view is None:
163+
return None
164+
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
165+
122166
async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
123167
"""Wait for the devbox to reach running state.
124168

src/runloop_api_client/sdk/devbox.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,50 @@ def get_info(
118118
**options,
119119
)
120120

121+
def get_tunnel(
122+
self,
123+
**options: Unpack[BaseRequestOptions],
124+
) -> TunnelView | None:
125+
"""Retrieve the V2 tunnel information for this devbox.
126+
127+
:param options: Optional request configuration
128+
:return: Tunnel details if a tunnel is enabled, None otherwise
129+
:rtype: :class:`~runloop_api_client.types.tunnel_view.TunnelView` | None
130+
131+
Example:
132+
>>> tunnel = devbox.get_tunnel()
133+
>>> if tunnel:
134+
... print(f"Tunnel key: {tunnel.tunnel_key}")
135+
"""
136+
info = self.get_info(**options)
137+
return info.tunnel
138+
139+
def get_tunnel_url(
140+
self,
141+
port: int,
142+
**options: Unpack[BaseRequestOptions],
143+
) -> str | None:
144+
"""Get the public tunnel URL for a specific port.
145+
146+
Constructs the tunnel URL using the format:
147+
``https://{port}-{tunnel_key}.tunnel.runloop.ai``
148+
149+
:param port: The port number to construct the URL for
150+
:type port: int
151+
:param options: Optional request configuration
152+
:return: The public tunnel URL if a tunnel is enabled, None otherwise
153+
:rtype: str | None
154+
155+
Example:
156+
>>> url = devbox.get_tunnel_url(8080)
157+
>>> if url:
158+
... print(f"Access your service at: {url}")
159+
"""
160+
tunnel_view = self.get_tunnel(**options)
161+
if tunnel_view is None:
162+
return None
163+
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
164+
121165
def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
122166
"""Wait for the devbox to reach running state.
123167

tests/sdk/async_devbox/test_core.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,79 @@ def test_net_property(self, mock_async_client: AsyncMock) -> None:
323323
net = devbox.net
324324
assert isinstance(net, AsyncNetworkInterface)
325325
assert net._devbox is devbox
326+
327+
@pytest.mark.asyncio
328+
async def test_get_tunnel_returns_tunnel_view(self, mock_async_client: AsyncMock) -> None:
329+
"""Test get_tunnel returns the tunnel from get_info."""
330+
tunnel_view = SimpleNamespace(
331+
tunnel_key="abc123xyz",
332+
auth_mode="open",
333+
create_time_ms=1234567890000,
334+
)
335+
devbox_view_with_tunnel = SimpleNamespace(
336+
id="dbx_123",
337+
status="running",
338+
tunnel=tunnel_view,
339+
)
340+
mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view_with_tunnel)
341+
342+
devbox = AsyncDevbox(mock_async_client, "dbx_123")
343+
result = await devbox.get_tunnel()
344+
345+
assert result is not None
346+
assert result == tunnel_view
347+
assert result.tunnel_key == "abc123xyz"
348+
mock_async_client.devboxes.retrieve.assert_called_once_with("dbx_123")
349+
350+
@pytest.mark.asyncio
351+
async def test_get_tunnel_returns_none_when_no_tunnel(self, mock_async_client: AsyncMock) -> None:
352+
"""Test get_tunnel returns None when no tunnel is enabled."""
353+
devbox_view_no_tunnel = SimpleNamespace(
354+
id="dbx_123",
355+
status="running",
356+
tunnel=None,
357+
)
358+
mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view_no_tunnel)
359+
360+
devbox = AsyncDevbox(mock_async_client, "dbx_123")
361+
result = await devbox.get_tunnel()
362+
363+
assert result is None
364+
mock_async_client.devboxes.retrieve.assert_called_once_with("dbx_123")
365+
366+
@pytest.mark.asyncio
367+
async def test_get_tunnel_url_constructs_url(self, mock_async_client: AsyncMock) -> None:
368+
"""Test get_tunnel_url constructs the correct URL."""
369+
tunnel_view = SimpleNamespace(
370+
tunnel_key="abc123xyz",
371+
auth_mode="open",
372+
create_time_ms=1234567890000,
373+
)
374+
devbox_view_with_tunnel = SimpleNamespace(
375+
id="dbx_123",
376+
status="running",
377+
tunnel=tunnel_view,
378+
)
379+
mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view_with_tunnel)
380+
381+
devbox = AsyncDevbox(mock_async_client, "dbx_123")
382+
result = await devbox.get_tunnel_url(8080)
383+
384+
assert result == "https://8080-abc123xyz.tunnel.runloop.ai"
385+
mock_async_client.devboxes.retrieve.assert_called_once_with("dbx_123")
386+
387+
@pytest.mark.asyncio
388+
async def test_get_tunnel_url_returns_none_when_no_tunnel(self, mock_async_client: AsyncMock) -> None:
389+
"""Test get_tunnel_url returns None when no tunnel is enabled."""
390+
devbox_view_no_tunnel = SimpleNamespace(
391+
id="dbx_123",
392+
status="running",
393+
tunnel=None,
394+
)
395+
mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view_no_tunnel)
396+
397+
devbox = AsyncDevbox(mock_async_client, "dbx_123")
398+
result = await devbox.get_tunnel_url(8080)
399+
400+
assert result is None
401+
mock_async_client.devboxes.retrieve.assert_called_once_with("dbx_123")

tests/sdk/devbox/test_core.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,3 +322,75 @@ def test_net_property(self, mock_client: Mock) -> None:
322322
net = devbox.net
323323
assert isinstance(net, NetworkInterface)
324324
assert net._devbox is devbox
325+
326+
def test_get_tunnel_returns_tunnel_view(self, mock_client: Mock) -> None:
327+
"""Test get_tunnel returns the tunnel from get_info."""
328+
tunnel_view = SimpleNamespace(
329+
tunnel_key="abc123xyz",
330+
auth_mode="open",
331+
create_time_ms=1234567890000,
332+
)
333+
devbox_view_with_tunnel = SimpleNamespace(
334+
id="dbx_123",
335+
status="running",
336+
tunnel=tunnel_view,
337+
)
338+
mock_client.devboxes.retrieve.return_value = devbox_view_with_tunnel
339+
340+
devbox = Devbox(mock_client, "dbx_123")
341+
result = devbox.get_tunnel()
342+
343+
assert result is not None
344+
assert result == tunnel_view
345+
assert result.tunnel_key == "abc123xyz"
346+
mock_client.devboxes.retrieve.assert_called_once_with("dbx_123")
347+
348+
def test_get_tunnel_returns_none_when_no_tunnel(self, mock_client: Mock) -> None:
349+
"""Test get_tunnel returns None when no tunnel is enabled."""
350+
devbox_view_no_tunnel = SimpleNamespace(
351+
id="dbx_123",
352+
status="running",
353+
tunnel=None,
354+
)
355+
mock_client.devboxes.retrieve.return_value = devbox_view_no_tunnel
356+
357+
devbox = Devbox(mock_client, "dbx_123")
358+
result = devbox.get_tunnel()
359+
360+
assert result is None
361+
mock_client.devboxes.retrieve.assert_called_once_with("dbx_123")
362+
363+
def test_get_tunnel_url_constructs_url(self, mock_client: Mock) -> None:
364+
"""Test get_tunnel_url constructs the correct URL."""
365+
tunnel_view = SimpleNamespace(
366+
tunnel_key="abc123xyz",
367+
auth_mode="open",
368+
create_time_ms=1234567890000,
369+
)
370+
devbox_view_with_tunnel = SimpleNamespace(
371+
id="dbx_123",
372+
status="running",
373+
tunnel=tunnel_view,
374+
)
375+
mock_client.devboxes.retrieve.return_value = devbox_view_with_tunnel
376+
377+
devbox = Devbox(mock_client, "dbx_123")
378+
result = devbox.get_tunnel_url(8080)
379+
380+
assert result == "https://8080-abc123xyz.tunnel.runloop.ai"
381+
mock_client.devboxes.retrieve.assert_called_once_with("dbx_123")
382+
383+
def test_get_tunnel_url_returns_none_when_no_tunnel(self, mock_client: Mock) -> None:
384+
"""Test get_tunnel_url returns None when no tunnel is enabled."""
385+
devbox_view_no_tunnel = SimpleNamespace(
386+
id="dbx_123",
387+
status="running",
388+
tunnel=None,
389+
)
390+
mock_client.devboxes.retrieve.return_value = devbox_view_no_tunnel
391+
392+
devbox = Devbox(mock_client, "dbx_123")
393+
result = devbox.get_tunnel_url(8080)
394+
395+
assert result is None
396+
mock_client.devboxes.retrieve.assert_called_once_with("dbx_123")

0 commit comments

Comments
 (0)