Skip to content

Commit c1e8f09

Browse files
authored
feat(devbox): added devbox.shell(shellName) command and stateful shell class to SDK (#696)
* cp dines * cp dines * cp dines
1 parent 6cc8c2f commit c1e8f09

File tree

5 files changed

+897
-2
lines changed

5 files changed

+897
-2
lines changed

src/runloop_api_client/sdk/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
AsyncBlueprintOps,
1414
AsyncStorageObjectOps,
1515
)
16-
from .devbox import Devbox
16+
from .devbox import Devbox, NamedShell
1717
from .snapshot import Snapshot
1818
from .blueprint import Blueprint
1919
from .execution import Execution
20-
from .async_devbox import AsyncDevbox
20+
from .async_devbox import AsyncDevbox, AsyncNamedShell
2121
from .async_snapshot import AsyncSnapshot
2222
from .storage_object import StorageObject
2323
from .async_blueprint import AsyncBlueprint
@@ -52,4 +52,6 @@
5252
"AsyncSnapshot",
5353
"StorageObject",
5454
"AsyncStorageObject",
55+
"NamedShell",
56+
"AsyncNamedShell",
5557
]

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,39 @@ def net(self) -> AsyncNetworkInterface:
278278
"""
279279
return AsyncNetworkInterface(self)
280280

281+
def shell(self, shell_name: str | None = None) -> AsyncNamedShell:
282+
"""Create a named shell instance for stateful command execution.
283+
284+
Named shells are stateful and maintain environment variables and the current working
285+
directory (CWD) across commands, just like a real shell on your local computer.
286+
Commands executed through the same named shell instance will execute sequentially -
287+
the shell can only run one command at a time with automatic queuing. This ensures
288+
that environment changes and directory changes from one command are preserved for
289+
the next command.
290+
291+
:param shell_name: The name of the persistent shell session. If not provided, a UUID will be generated automatically.
292+
:type shell_name: str | None, optional
293+
:return: An AsyncNamedShell instance for executing commands in the named shell
294+
:rtype: AsyncNamedShell
295+
296+
Example:
297+
>>> # Create a named shell with a custom name
298+
>>> shell = await devbox.shell("my-session")
299+
>>> # Create a named shell with an auto-generated UUID name
300+
>>> shell2 = await devbox.shell()
301+
>>> # Commands execute sequentially and share state
302+
>>> await shell.exec("cd /app")
303+
>>> await shell.exec("export MY_VAR=value")
304+
>>> result = await shell.exec("echo $MY_VAR") # Will output 'value'
305+
>>> result = await shell.exec("pwd") # Will output '/app'
306+
"""
307+
if shell_name is None:
308+
# uuid_utils is not typed
309+
from uuid_utils import uuid7 # type: ignore
310+
311+
shell_name = str(uuid7())
312+
return AsyncNamedShell(self, shell_name)
313+
281314
# ------------------------------------------------------------------ #
282315
# Internal helpers
283316
# ------------------------------------------------------------------ #
@@ -550,6 +583,105 @@ async def upload(
550583
)
551584

552585

586+
class AsyncNamedShell:
587+
"""Interface for executing commands in a persistent, stateful shell session.
588+
589+
Named shells are stateful and maintain environment variables and the current working
590+
directory (CWD) across commands. Commands executed through the same named shell
591+
instance will execute sequentially - the shell can only run one command at a time
592+
with automatic queuing. This ensures that environment changes and directory changes
593+
from one command are preserved for the next command.
594+
595+
Use :meth:`AsyncDevbox.shell` to create a named shell instance. If you use the same
596+
shell name, it will re-attach to the existing named shell, preserving its state.
597+
598+
Example:
599+
>>> shell = await devbox.shell("my-session")
600+
>>> await shell.exec("cd /app")
601+
>>> await shell.exec("export MY_VAR=value")
602+
>>> result = await shell.exec("echo $MY_VAR") # Will output 'value'
603+
>>> result = await shell.exec("pwd") # Will output '/app'
604+
"""
605+
606+
def __init__(self, devbox: AsyncDevbox, shell_name: str) -> None:
607+
"""Initialize the named shell.
608+
609+
:param devbox: The async devbox instance to execute commands on
610+
:type devbox: AsyncDevbox
611+
:param shell_name: The name of the persistent shell session
612+
:type shell_name: str
613+
"""
614+
self._devbox = devbox
615+
self._shell_name = shell_name
616+
617+
async def exec(
618+
self,
619+
command: str,
620+
**params: Unpack[SDKDevboxExecuteParams],
621+
) -> AsyncExecutionResult:
622+
"""Execute a command in the named shell and wait for it to complete.
623+
624+
The command will execute in the persistent shell session, maintaining environment
625+
variables and the current working directory from previous commands. Commands are
626+
queued and execute sequentially - only one command runs at a time in the named shell.
627+
628+
Optionally provide callbacks to stream logs in real-time. When callbacks are provided,
629+
this method waits for both the command to complete AND all streaming data to be
630+
processed before returning.
631+
632+
:param command: The command to execute
633+
:type command: str
634+
:param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxExecuteParams` for available parameters
635+
:return: Wrapper with exit status and output helpers
636+
:rtype: AsyncExecutionResult
637+
638+
Example:
639+
>>> shell = await devbox.shell("my-session")
640+
>>> result = await shell.exec("ls -la")
641+
>>> print(await result.stdout())
642+
>>> # With streaming callbacks
643+
>>> result = await shell.exec("npm install", stdout=lambda line: print(f"[LOG] {line}"))
644+
"""
645+
# Ensure shell_name is set and cannot be overridden by user params
646+
params = dict(params)
647+
params["shell_name"] = self._shell_name
648+
return await self._devbox.cmd.exec(command, **params)
649+
650+
async def exec_async(
651+
self,
652+
command: str,
653+
**params: Unpack[SDKDevboxExecuteAsyncParams],
654+
) -> AsyncExecution:
655+
"""Execute a command in the named shell asynchronously without waiting for completion.
656+
657+
The command will execute in the persistent shell session, maintaining environment
658+
variables and the current working directory from previous commands. Commands are
659+
queued and execute sequentially - only one command runs at a time in the named shell.
660+
661+
Optionally provide callbacks to stream logs in real-time as they are produced.
662+
Callbacks fire in real-time as logs arrive. When you call execution.result(),
663+
it will wait for both the command to complete and all streaming to finish.
664+
665+
:param command: The command to execute
666+
:type command: str
667+
:param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxExecuteAsyncParams` for available parameters
668+
:return: Handle for managing the running process
669+
:rtype: AsyncExecution
670+
671+
Example:
672+
>>> shell = await devbox.shell("my-session")
673+
>>> execution = await shell.exec_async("long-running-task.sh", stdout=lambda line: print(f"[LOG] {line}"))
674+
>>> # Do other work while command runs...
675+
>>> result = await execution.result()
676+
>>> if result.success:
677+
... print("Task completed successfully!")
678+
"""
679+
# Ensure shell_name is set and cannot be overridden by user params
680+
params = dict(params)
681+
params["shell_name"] = self._shell_name
682+
return await self._devbox.cmd.exec_async(command, **params)
683+
684+
553685
class AsyncNetworkInterface:
554686
"""Interface for networking operations on a devbox.
555687

src/runloop_api_client/sdk/devbox.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,39 @@ def net(self) -> NetworkInterface:
280280
"""
281281
return NetworkInterface(self)
282282

283+
def shell(self, shell_name: str | None = None) -> NamedShell:
284+
"""Create a named shell instance for stateful command execution.
285+
286+
Named shells are stateful and maintain environment variables and the current working
287+
directory (CWD) across commands, just like a real shell on your local computer.
288+
Commands executed through the same named shell instance will execute sequentially -
289+
the shell can only run one command at a time with automatic queuing. This ensures
290+
that environment changes and directory changes from one command are preserved for
291+
the next command.
292+
293+
:param shell_name: The name of the persistent shell session. If not provided, a UUID will be generated automatically.
294+
:type shell_name: str | None, optional
295+
:return: A NamedShell instance for executing commands in the named shell
296+
:rtype: NamedShell
297+
298+
Example:
299+
>>> # Create a named shell with a custom name
300+
>>> shell = devbox.shell("my-session")
301+
>>> # Create a named shell with an auto-generated UUID name
302+
>>> shell2 = devbox.shell()
303+
>>> # Commands execute sequentially and share state
304+
>>> shell.exec("cd /app")
305+
>>> shell.exec("export MY_VAR=value")
306+
>>> result = shell.exec("echo $MY_VAR") # Will output 'value'
307+
>>> result = shell.exec("pwd") # Will output '/app'
308+
"""
309+
if shell_name is None:
310+
# uuid_utils is not typed
311+
from uuid_utils import uuid7 # type: ignore
312+
313+
shell_name = str(uuid7())
314+
return NamedShell(self, shell_name)
315+
283316
# --------------------------------------------------------------------- #
284317
# Internal helpers
285318
# --------------------------------------------------------------------- #
@@ -558,6 +591,105 @@ def upload(
558591
)
559592

560593

594+
class NamedShell:
595+
"""Interface for executing commands in a persistent, stateful shell session.
596+
597+
Named shells are stateful and maintain environment variables and the current working
598+
directory (CWD) across commands. Commands executed through the same named shell
599+
instance will execute sequentially - the shell can only run one command at a time
600+
with automatic queuing. This ensures that environment changes and directory changes
601+
from one command are preserved for the next command.
602+
603+
Use :meth:`Devbox.shell` to create a named shell instance. If you use the same shell
604+
name, it will re-attach to the existing named shell, preserving its state.
605+
606+
Example:
607+
>>> shell = devbox.shell("my-session")
608+
>>> shell.exec("cd /app")
609+
>>> shell.exec("export MY_VAR=value")
610+
>>> result = shell.exec("echo $MY_VAR") # Will output 'value'
611+
>>> result = shell.exec("pwd") # Will output '/app'
612+
"""
613+
614+
def __init__(self, devbox: Devbox, shell_name: str) -> None:
615+
"""Initialize the named shell.
616+
617+
:param devbox: The devbox instance to execute commands on
618+
:type devbox: Devbox
619+
:param shell_name: The name of the persistent shell session
620+
:type shell_name: str
621+
"""
622+
self._devbox = devbox
623+
self._shell_name = shell_name
624+
625+
def exec(
626+
self,
627+
command: str,
628+
**params: Unpack[SDKDevboxExecuteParams],
629+
) -> ExecutionResult:
630+
"""Execute a command in the named shell and wait for it to complete.
631+
632+
The command will execute in the persistent shell session, maintaining environment
633+
variables and the current working directory from previous commands. Commands are
634+
queued and execute sequentially - only one command runs at a time in the named shell.
635+
636+
Optionally provide callbacks to stream logs in real-time. When callbacks are provided,
637+
this method waits for both the command to complete AND all streaming data to be
638+
processed before returning.
639+
640+
:param command: The command to execute
641+
:type command: str
642+
:param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxExecuteParams` for available parameters
643+
:return: Wrapper with exit status and output helpers
644+
:rtype: ExecutionResult
645+
646+
Example:
647+
>>> shell = devbox.shell("my-session")
648+
>>> result = shell.exec("ls -la")
649+
>>> print(result.stdout())
650+
>>> # With streaming callbacks
651+
>>> result = shell.exec("npm install", stdout=lambda line: print(f"[LOG] {line}"))
652+
"""
653+
# Ensure shell_name is set and cannot be overridden by user params
654+
params = dict(params)
655+
params["shell_name"] = self._shell_name
656+
return self._devbox.cmd.exec(command, **params)
657+
658+
def exec_async(
659+
self,
660+
command: str,
661+
**params: Unpack[SDKDevboxExecuteAsyncParams],
662+
) -> Execution:
663+
"""Execute a command in the named shell asynchronously without waiting for completion.
664+
665+
The command will execute in the persistent shell session, maintaining environment
666+
variables and the current working directory from previous commands. Commands are
667+
queued and execute sequentially - only one command runs at a time in the named shell.
668+
669+
Optionally provide callbacks to stream logs in real-time as they are produced.
670+
Callbacks fire in real-time as logs arrive. When you call execution.result(),
671+
it will wait for both the command to complete and all streaming to finish.
672+
673+
:param command: The command to execute
674+
:type command: str
675+
:param params: See :typeddict:`~runloop_api_client.sdk._types.SDKDevboxExecuteAsyncParams` for available parameters
676+
:return: Handle for managing the running process
677+
:rtype: Execution
678+
679+
Example:
680+
>>> shell = devbox.shell("my-session")
681+
>>> execution = shell.exec_async("long-running-task.sh", stdout=lambda line: print(f"[LOG] {line}"))
682+
>>> # Do other work while command runs...
683+
>>> result = execution.result()
684+
>>> if result.success:
685+
... print("Task completed successfully!")
686+
"""
687+
# Ensure shell_name is set and cannot be overridden by user params
688+
params = dict(params)
689+
params["shell_name"] = self._shell_name
690+
return self._devbox.cmd.exec_async(command, **params)
691+
692+
561693
class NetworkInterface:
562694
"""Interface for network operations on a devbox.
563695

0 commit comments

Comments
 (0)