diff --git a/README.md b/README.md
index 98c74d73..bc35dbbf 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,11 @@
OpenSandbox
- [](https://github.com/alibaba/OpenSandbox)
- [](https://deepwiki.com/alibaba/OpenSandbox)
- [](https://www.apache.org/licenses/LICENSE-2.0.html)
- [](https://badge.fury.io/py/opensandbox)
- [](https://github.com/alibaba/OpenSandbox/actions)
-
+[](https://github.com/alibaba/OpenSandbox)
+[](https://deepwiki.com/alibaba/OpenSandbox)
+[](https://www.apache.org/licenses/LICENSE-2.0.html)
+[](https://badge.fury.io/py/opensandbox)
+[](https://github.com/alibaba/OpenSandbox/actions)
@@ -63,7 +62,7 @@ Create a sandbox and execute commands
import asyncio
from datetime import timedelta
-from code_interpreter import CodeInterpreter, SupportedLanguage, CodeContext
+from code_interpreter import CodeInterpreter, SupportedLanguage
from opensandbox import Sandbox
from opensandbox.models import WriteEntry
@@ -94,7 +93,7 @@ async def main() -> None:
# 5. Create a code interpreter
interpreter = await CodeInterpreter.create(sandbox)
- # 6. Execute Python code
+ # 6. Execute Python code (single-run, pass language directly)
result = await interpreter.codes.run(
"""
import sys
@@ -102,7 +101,7 @@ async def main() -> None:
result = 2 + 2
result
""",
- context=CodeContext(language=SupportedLanguage.PYTHON)
+ language=SupportedLanguage.PYTHON,
)
print(result.result[0].text) # 4
@@ -122,6 +121,7 @@ OpenSandbox provides rich examples demonstrating sandbox usage in different scen
#### 🎯 Basic Examples
- **[code-interpreter](examples/code-interpreter/README.md)** - Complete Code Interpreter SDK example
+
- Run commands and execute Python/Java/Go/TypeScript code inside a sandbox
- Covers context creation, code execution, and result streaming
- Supports custom language versions
diff --git a/docs/README_zh.md b/docs/README_zh.md
index c98a71a7..1c1ab812 100644
--- a/docs/README_zh.md
+++ b/docs/README_zh.md
@@ -8,7 +8,6 @@
[](https://www.apache.org/licenses/LICENSE-2.0.html)
[](https://badge.fury.io/py/opensandbox)
-
@@ -62,7 +61,7 @@ uv pip install opensandbox-code-interpreter
import asyncio
from datetime import timedelta
-from code_interpreter import CodeInterpreter, SupportedLanguage, CodeContext
+from code_interpreter import CodeInterpreter, SupportedLanguage
from opensandbox import Sandbox
from opensandbox.models import WriteEntry
@@ -93,7 +92,7 @@ async def main() -> None:
# 5. Create a code interpreter
interpreter = await CodeInterpreter.create(sandbox)
- # 6. Execute a Python code
+ # 6. 执行 Python 代码(单次执行:直接传 language)
result = await interpreter.codes.run(
"""
import sys
@@ -101,7 +100,7 @@ async def main() -> None:
result = 2 + 2
result
""",
- context=CodeContext(language=SupportedLanguage.PYTHON)
+ language=SupportedLanguage.PYTHON,
)
print(result.result[0].text) # 4
diff --git a/examples/code-interpreter/main.py b/examples/code-interpreter/main.py
index 67d9080c..708fe86a 100644
--- a/examples/code-interpreter/main.py
+++ b/examples/code-interpreter/main.py
@@ -45,13 +45,12 @@ async def main() -> None:
interpreter = await CodeInterpreter.create(sandbox=sandbox)
# Python example: show runtime info and return a simple calculation.
- py_ctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)
py_exec = await interpreter.codes.run(
"import platform\n"
"print('Hello from Python!')\n"
"result = {'py': platform.python_version(), 'sum': 2 + 2}\n"
"result",
- context=py_ctx,
+ language=SupportedLanguage.PYTHON,
)
print("\n=== Python example ===")
for msg in py_exec.logs.stdout:
@@ -61,13 +60,12 @@ async def main() -> None:
print(f"[Python result] {res.text}")
# Java example: print to stdout and return the final result line.
- java_ctx = await interpreter.codes.create_context(SupportedLanguage.JAVA)
java_exec = await interpreter.codes.run(
"System.out.println(\"Hello from Java!\");\n"
"int result = 2 + 3;\n"
"System.out.println(\"2 + 3 = \" + result);\n"
"result",
- context=java_ctx,
+ language=SupportedLanguage.JAVA,
)
print("\n=== Java example ===")
for msg in java_exec.logs.stdout:
@@ -79,7 +77,6 @@ async def main() -> None:
print(f"[Java error] {java_exec.error.name}: {java_exec.error.value}")
# Go example: print logs and demonstrate a main function structure.
- go_ctx = await interpreter.codes.create_context(SupportedLanguage.GO)
go_exec = await interpreter.codes.run(
"package main\n"
"import \"fmt\"\n"
@@ -88,7 +85,7 @@ async def main() -> None:
" sum := 3 + 4\n"
" fmt.Println(\"3 + 4 =\", sum)\n"
"}",
- context=go_ctx,
+ language=SupportedLanguage.GO,
)
print("\n=== Go example ===")
for msg in go_exec.logs.stdout:
@@ -97,12 +94,11 @@ async def main() -> None:
print(f"[Go error] {go_exec.error.name}: {go_exec.error.value}")
# TypeScript example: use typing and sum an array.
- ts_ctx = await interpreter.codes.create_context(SupportedLanguage.TYPESCRIPT)
ts_exec = await interpreter.codes.run(
"console.log('Hello from TypeScript!');\n"
"const nums: number[] = [1, 2, 3];\n"
"console.log('sum =', nums.reduce((a, b) => a + b, 0));",
- context=ts_ctx,
+ language=SupportedLanguage.TYPESCRIPT,
)
print("\n=== TypeScript example ===")
for msg in ts_exec.logs.stdout:
@@ -110,7 +106,7 @@ async def main() -> None:
if ts_exec.error:
print(f"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}")
- await interpreter.kill()
+ await sandbox.kill()
if __name__ == "__main__":
diff --git a/sdks/code-interpreter/kotlin/README.md b/sdks/code-interpreter/kotlin/README.md
index 78e71dbf..0e8b3fc5 100644
--- a/sdks/code-interpreter/kotlin/README.md
+++ b/sdks/code-interpreter/kotlin/README.md
@@ -83,7 +83,7 @@ public class QuickStart {
// 7. Cleanup
// Note: kill() terminates the remote instance; close() (auto-called) cleans up local resources
- interpreter.kill();
+ sandbox.kill();
} catch (SandboxException e) {
// Handle Sandbox specific exceptions
System.err.println("Sandbox Error: [" + e.getError().getCode() + "] " + e.getError().getMessage());
@@ -124,6 +124,21 @@ Sandbox sandbox = Sandbox.builder()
## Usage Examples
+### 0. Run with `language` (default language context)
+
+If you don't need to manage explicit session IDs, you can run code by specifying only `language`.
+When `context.id` is omitted, **execd will create/reuse a default session for that language**, so
+state can persist across runs:
+
+```java
+import com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;
+
+// Default Python context: state persists across runs
+interpreter.codes().run("x = 42", SupportedLanguage.PYTHON);
+Execution execution = interpreter.codes().run("result = x\nresult", SupportedLanguage.PYTHON);
+System.out.println(execution.getResult().get(0).getText()); // 42
+```
+
### 1. Java Code Execution
Execute Java code snippets dynamically.
diff --git a/sdks/code-interpreter/kotlin/README_zh.md b/sdks/code-interpreter/kotlin/README_zh.md
index a24b5bc6..78d77e09 100644
--- a/sdks/code-interpreter/kotlin/README_zh.md
+++ b/sdks/code-interpreter/kotlin/README_zh.md
@@ -83,7 +83,7 @@ public class QuickStart {
// 7. 清理资源
// 注意: kill() 会立即终止远程沙箱实例;try-with-resources 会自动调用 close() 清理本地资源
- interpreter.kill();
+ sandbox.kill();
} catch (SandboxException e) {
// 处理 Sandbox 特定异常
System.err.println("沙箱错误: [" + e.getError().getCode() + "] " + e.getError().getMessage());
@@ -124,6 +124,20 @@ Sandbox sandbox = Sandbox.builder()
## 核心功能示例
+### 0. 直接传 `language`(使用该语言默认上下文)
+
+如果你不需要显式管理 session id,可以只传 `language` 来执行代码。
+当 `context.id` 省略时,**execd 会为该语言创建/复用默认 session**,因此状态可以跨次执行保持:
+
+```java
+import com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;
+
+// Python 默认上下文:状态会在多次 run 之间保持
+interpreter.codes().run("x = 42", SupportedLanguage.PYTHON);
+Execution execution = interpreter.codes().run("result = x\nresult", SupportedLanguage.PYTHON);
+System.out.println(execution.getResult().get(0).getText()); // 42
+```
+
### 1. Java 代码执行
动态执行 Java 代码片段。
diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreter.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreter.kt
index 047903c0..e7f4e16e 100644
--- a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreter.kt
+++ b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreter.kt
@@ -23,12 +23,7 @@ import com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentExceptio
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxInternalException
import com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EXECD_PORT
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics
import org.slf4j.LoggerFactory
-import java.time.Duration
-import java.time.OffsetDateTime
import java.util.UUID
/**
@@ -185,105 +180,6 @@ class CodeInterpreter internal constructor(
}
}
- /**
- * Gets a specific network endpoint for the underlying sandbox.
- *
- * This allows access to specific ports exposed by the sandbox, which can be
- * useful for connecting to additional services or debugging interfaces.
- *
- * @param port The port number to get the endpoint for
- * @return Endpoint information including host, port, and connection details
- * @throws SandboxException if endpoint cannot be retrieved
- */
- fun getEndpoint(port: Int): SandboxEndpoint {
- return sandbox.getEndpoint(port)
- }
-
- /**
- * Gets the current status of this sandbox.
- *
- * @return Current sandbox status including state and metadata
- * @throws SandboxException if status cannot be retrieved
- */
- fun getInfo(): SandboxInfo {
- return sandbox.getInfo()
- }
-
- /**
- * Gets the current resource usage metrics for the underlying sandbox.
- *
- * Provides real-time information about CPU usage, memory consumption,
- * disk I/O, and other performance metrics.
- *
- * @return Current sandbox metrics including CPU, memory, and I/O statistics
- * @throws SandboxException if metrics cannot be retrieved
- */
- fun getMetrics(): SandboxMetrics {
- return sandbox.getMetrics()
- }
-
- /**
- * Renew the sandbox expiration time to delay automatic termination.
- *
- * The new expiration time will be set to the current time plus the provided duration.
- *
- * @param timeout Duration to add to the current time to set the new expiration
- * @throws SandboxException if the operation fails
- */
- fun renew(timeout: Duration) {
- logger.info("Renew code interpreter {} timeout, estimated expiration to {}", id, OffsetDateTime.now().plus(timeout))
- sandbox.renew(timeout)
- }
-
- /**
- * Pauses the sandbox while preserving its state.
- *
- * The sandbox will transition to PAUSED state and can be resumed later.
- * All running processes will be suspended.
- *
- * @throws SandboxException if pause operation fails
- */
- fun pause() {
- logger.info("Pausing code interpreter: {}", id)
- sandbox.pause()
- }
-
- /**
- * Resumes a previously paused code interpreter.
- *
- * The sandbox will transition from PAUSED to RUNNING state and all
- * suspended processes will be resumed.
- *
- * @throws SandboxException if resume operation fails
- */
- fun resume() {
- logger.info("Resuming code interpreter: {}", id)
- sandbox.resume()
- }
-
- /**
- * This method sends a termination signal to the remote sandbox instance, causing it to stop immediately.
- * This is an irreversible operation.
- *
- * Note: This method does NOT close the local `Sandbox` object resources (like connection pools).
- * You should call `close()` or use a try-with-resources block to clean up local resources.
- *
- * @throws SandboxException if termination fails
- */
- fun kill() {
- logger.info("Killing code interpreter: {}", id)
- sandbox.kill()
- }
-
- /**
- * Checks if the code interpreter and its underlying sandbox are healthy and responsive.
- *
- * This performs health checks on both the sandbox infrastructure and code execution services.
- *
- * @return true if both sandbox and code execution services are healthy, false otherwise
- */
- fun isHealthy(): Boolean = sandbox.isHealthy()
-
/**
* Builder for creating CodeInterpreter instances from existing Sandbox instances.
*
diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/services/Codes.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/services/Codes.kt
index baa09b6a..7519adac 100644
--- a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/services/Codes.kt
+++ b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/services/Codes.kt
@@ -28,6 +28,27 @@ import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionH
* session persistence, and multi-language support.
*/
interface Codes {
+ /**
+ * Gets an existing execution context by id.
+ *
+ * A [CodeContext] represents a persistent execution session (kernel/runtime) that can be reused
+ * across multiple executions to preserve state (variables, imports, working directory, etc.).
+ *
+ * @param id Execution context id
+ * @return The existing [CodeContext]
+ */
+ fun getContext(id: String): CodeContext
+
+ /**
+ * Lists active execution contexts for a given language/runtime.
+ *
+ * This is useful for debugging, monitoring, or cleaning up leaked contexts.
+ *
+ * @param language Execution runtime (e.g., "python", "bash", "java")
+ * @return List of [CodeContext] currently available for the given language
+ */
+ fun listContexts(language: String): List
+
/**
* Creates a new execution context for code interpretation.
*
@@ -36,6 +57,24 @@ interface Codes {
*/
fun createContext(language: String): CodeContext
+ /**
+ * Deletes an execution context (session) by id.
+ *
+ * This should terminate the underlying context thread/process and release resources.
+ *
+ * @param id Execution context id to delete
+ */
+ fun deleteContext(id: String)
+
+ /**
+ * Deletes all execution contexts under a specific language/runtime.
+ *
+ * This is a bulk cleanup operation intended for context management.
+ *
+ * @param language Target execution runtime whose contexts should be deleted
+ */
+ fun deleteContexts(language: String)
+
/**
* Executes code within the specified context.
*
@@ -60,6 +99,60 @@ interface Codes {
return run(RunCodeRequest.builder().code(code).context(context).handlers(handlers).build())
}
+ /**
+ * Executes code within the specified context.
+ *
+ * @param code The code to run
+ * @param context The context to run code
+ * @return Execution with stdout, stderr, exit code, and execution metadata
+ */
+ fun run(
+ code: String,
+ context: CodeContext,
+ ): Execution {
+ return run(RunCodeRequest.builder().code(code).context(context).build())
+ }
+
+ /**
+ * Run code with specified language within the default context
+ *
+ * @param code The code to run
+ * @param language The language of code
+ * @param handlers execution events handlers
+ * @return Execution with stdout, stderr, exit code, and execution metadata
+ */
+ fun run(
+ code: String,
+ language: String,
+ handlers: ExecutionHandlers,
+ ): Execution {
+ return run(
+ RunCodeRequest
+ .builder()
+ .code(code)
+ .context(CodeContext.builder().language(language).build()).handlers(handlers).build(),
+ )
+ }
+
+ /**
+ * Run code with specified language within the default context
+ *
+ * @param code The code to run
+ * @param language The language of code
+ * @return Execution with stdout, stderr, exit code, and execution metadata
+ */
+ fun run(
+ code: String,
+ language: String,
+ ): Execution {
+ return run(
+ RunCodeRequest
+ .builder()
+ .code(code)
+ .context(CodeContext.builder().language(language).build()).build(),
+ )
+ }
+
/**
* Interrupts a currently running code execution.
*
diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt
index e2e3d7a6..177c8ff6 100644
--- a/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt
+++ b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt
@@ -52,6 +52,26 @@ class CodesAdapter(
private val api =
CodeInterpretingApi("${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}", httpClientProvider.httpClient)
+ override fun getContext(id: String): CodeContext {
+ try {
+ val result = api.getContext(id)
+ return result.toCodeContext()
+ } catch (e: Exception) {
+ logger.error("Failed to get context", e)
+ throw e.toSandboxException()
+ }
+ }
+
+ override fun listContexts(language: String): List {
+ try {
+ val list = api.listContexts(language)
+ return list.map { it.toCodeContext() }
+ } catch (e: Exception) {
+ logger.error("Failed to list contexts", e)
+ throw e.toSandboxException()
+ }
+ }
+
override fun createContext(language: String): CodeContext {
try {
val request = ApiCodeContextRequest(language = language)
@@ -63,6 +83,24 @@ class CodesAdapter(
}
}
+ override fun deleteContext(id: String) {
+ try {
+ api.deleteContext(id)
+ } catch (e: Exception) {
+ logger.error("Failed to delete context", e)
+ throw e.toSandboxException()
+ }
+ }
+
+ override fun deleteContexts(language: String) {
+ try {
+ deleteContexts(language)
+ } catch (e: Exception) {
+ logger.error("Failed to delete contexts", e)
+ throw e.toSandboxException()
+ }
+ }
+
override fun run(request: RunCodeRequest): Execution {
if (request.code.isEmpty()) {
throw InvalidArgumentException("Code cannot be empty")
diff --git a/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreterTest.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreterTest.kt
index 1a68ee4a..b7324819 100644
--- a/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreterTest.kt
+++ b/sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreterTest.kt
@@ -18,26 +18,19 @@ package com.alibaba.opensandbox.codeinterpreter
import com.alibaba.opensandbox.codeinterpreter.domain.services.Codes
import com.alibaba.opensandbox.sandbox.Sandbox
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics
import com.alibaba.opensandbox.sandbox.domain.services.Commands
import com.alibaba.opensandbox.sandbox.domain.services.Filesystem
import com.alibaba.opensandbox.sandbox.domain.services.Metrics
-import io.mockk.Runs
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
-import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
-import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
-import java.time.Duration
import java.util.UUID
@ExtendWith(MockKExtension::class)
@@ -98,77 +91,4 @@ class CodeInterpreterTest {
fun `codes should return code service`() {
assertSame(codeService, codeInterpreter.codes())
}
-
- @Test
- fun `getEndpoint should delegate to sandbox`() {
- val port = 8888
- val endpoint = mockk()
- every { sandbox.getEndpoint(port) } returns endpoint
-
- assertSame(endpoint, codeInterpreter.getEndpoint(port))
- verify { sandbox.getEndpoint(port) }
- }
-
- @Test
- fun `getInfo should delegate to sandbox`() {
- val info = mockk()
- every { sandbox.getInfo() } returns info
-
- assertSame(info, codeInterpreter.getInfo())
- verify { sandbox.getInfo() }
- }
-
- @Test
- fun `getMetrics should delegate to sandbox`() {
- val metrics = mockk()
- every { sandbox.getMetrics() } returns metrics
-
- assertSame(metrics, codeInterpreter.getMetrics())
- verify { sandbox.getMetrics() }
- }
-
- @Test
- fun `renew should delegate to sandbox`() {
- val timeout = Duration.ofMinutes(10)
- every { sandbox.renew(timeout) } just Runs
-
- codeInterpreter.renew(timeout)
-
- verify { sandbox.renew(timeout) }
- }
-
- @Test
- fun `pause should delegate to sandbox`() {
- every { sandbox.pause() } just Runs
-
- codeInterpreter.pause()
-
- verify { sandbox.pause() }
- }
-
- @Test
- fun `resume should delegate to sandbox`() {
- every { sandbox.resume() } just Runs
-
- codeInterpreter.resume()
-
- verify { sandbox.resume() }
- }
-
- @Test
- fun `kill should delegate to sandbox`() {
- every { sandbox.kill() } just Runs
-
- codeInterpreter.kill()
-
- verify { sandbox.kill() }
- }
-
- @Test
- fun `isHealthy should delegate to sandbox`() {
- every { sandbox.isHealthy() } returns true
-
- assertTrue(codeInterpreter.isHealthy())
- verify { sandbox.isHealthy() }
- }
}
diff --git a/sdks/code-interpreter/kotlin/gradle.properties b/sdks/code-interpreter/kotlin/gradle.properties
index 1f3d6d96..06e2ccfa 100644
--- a/sdks/code-interpreter/kotlin/gradle.properties
+++ b/sdks/code-interpreter/kotlin/gradle.properties
@@ -5,5 +5,5 @@ org.gradle.parallel=true
# Project metadata
project.group=com.alibaba.opensandbox
-project.version=1.0.0
+project.version=1.0.1
project.description=A Kotlin SDK for Code Interpreter
diff --git a/sdks/code-interpreter/kotlin/gradle/libs.versions.toml b/sdks/code-interpreter/kotlin/gradle/libs.versions.toml
index 8d8c6895..d7277b0d 100644
--- a/sdks/code-interpreter/kotlin/gradle/libs.versions.toml
+++ b/sdks/code-interpreter/kotlin/gradle/libs.versions.toml
@@ -23,7 +23,7 @@ spotless = "6.23.3"
maven-publish = "0.35.0"
dokka = "1.9.10"
jackson = "2.18.2"
-sandbox = "1.0.0"
+sandbox = "1.0.1"
junit-platform = "1.13.4"
[libraries]
diff --git a/sdks/code-interpreter/python/README.md b/sdks/code-interpreter/python/README.md
index 61cf229e..a77dffa5 100644
--- a/sdks/code-interpreter/python/README.md
+++ b/sdks/code-interpreter/python/README.md
@@ -77,12 +77,16 @@ async def main() -> None:
context=context,
)
+ # Alternatively, you can pass a language directly (recommended: SupportedLanguage.*).
+ # This uses the default context for that language (state can persist across runs).
+ # result = await interpreter.codes.run("print('hi')", language=SupportedLanguage.PYTHON)
+
# 7. Print output
if result.result:
print(result.result[0].text)
# 8. Cleanup remote instance (optional but recommended)
- await interpreter.kill()
+ await sandbox.kill()
if __name__ == "__main__":
@@ -119,7 +123,7 @@ with sandbox:
result = interpreter.codes.run("result = 2 + 2\nresult")
if result.result:
print(result.result[0].text)
- interpreter.kill()
+ sandbox.kill()
```
## Runtime Configuration
@@ -143,6 +147,32 @@ creating the `Sandbox`.
## Usage Examples
+### 0. Run with `language` (default language context)
+
+You can pass `language` directly (recommended: `SupportedLanguage.*`) and skip `create_context`.
+When `context.id` is omitted, **execd will create/reuse a default session for that language**, so
+state can persist across runs:
+
+```python
+from code_interpreter import SupportedLanguage
+
+execution = await interpreter.codes.run(
+ "result = 2 + 2\nresult",
+ language=SupportedLanguage.PYTHON,
+)
+assert execution.result and execution.result[0].text == "4"
+```
+
+State persistence example (default Python context):
+
+```python
+from code_interpreter import SupportedLanguage
+
+await interpreter.codes.run("x = 42", language=SupportedLanguage.PYTHON)
+execution = await interpreter.codes.run("result = x\nresult", language=SupportedLanguage.PYTHON)
+assert execution.result and execution.result[0].text == "42"
+```
+
### 1. Java Code Execution
```python
diff --git a/sdks/code-interpreter/python/README_zh.md b/sdks/code-interpreter/python/README_zh.md
index 14b17819..d8f986f8 100644
--- a/sdks/code-interpreter/python/README_zh.md
+++ b/sdks/code-interpreter/python/README_zh.md
@@ -73,12 +73,15 @@ async def main() -> None:
context=context,
)
+ # 或者:直接传入 language(推荐使用 SupportedLanguage.*),使用该语言默认上下文执行(可跨次保持状态)
+ # result = await interpreter.codes.run("print('hi')", language=SupportedLanguage.PYTHON)
+
# 7. 打印输出
if result.result:
print(result.result[0].text)
# 8. 清理远程实例(可选,但推荐)
- await interpreter.kill()
+ await sandbox.kill()
if __name__ == "__main__":
@@ -115,7 +118,7 @@ with sandbox:
result = interpreter.codes.run("result = 2 + 2\nresult")
if result.result:
print(result.result[0].text)
- interpreter.kill()
+ sandbox.kill()
```
## 运行时配置
@@ -137,6 +140,31 @@ Code Interpreter SDK 依赖于特定的运行环境。请确保你的沙箱服
## 核心功能示例
+### 0. 直接传 `language`(使用该语言默认上下文)
+
+可以直接传入 `language`(推荐:`SupportedLanguage.*`),跳过 `create_context`。
+当 `context.id` 省略时,**execd 会为该语言创建/复用默认 session**,因此状态可以跨次执行保持:
+
+```python
+from code_interpreter import SupportedLanguage
+
+execution = await interpreter.codes.run(
+ "result = 2 + 2\nresult",
+ language=SupportedLanguage.PYTHON,
+)
+assert execution.result and execution.result[0].text == "4"
+```
+
+状态持久化示例(Python 默认上下文):
+
+```python
+from code_interpreter import SupportedLanguage
+
+await interpreter.codes.run("x = 42", language=SupportedLanguage.PYTHON)
+execution = await interpreter.codes.run("result = x\nresult", language=SupportedLanguage.PYTHON)
+assert execution.result and execution.result[0].text == "42"
+```
+
### 1. Java 代码执行
```python
diff --git a/sdks/code-interpreter/python/pyproject.toml b/sdks/code-interpreter/python/pyproject.toml
index bab66837..1e25a0c9 100644
--- a/sdks/code-interpreter/python/pyproject.toml
+++ b/sdks/code-interpreter/python/pyproject.toml
@@ -105,17 +105,24 @@ ignore = [
"__init__.py" = ["F401"]
[tool.pyright]
-typeCheckingMode = "strict"
+typeCheckingMode = "standard"
pythonVersion = "3.10"
+pythonPlatform = "All"
+
include = ["src"]
-venvPath = "."
-venv = ".venv"
+
exclude = [
"**/node_modules",
"**/__pycache__",
- "**/.*",
+ "src/opensandbox/api/**",
]
+venvPath = "."
+venv = ".venv"
+
+reportMissingImports = true
+reportMissingTypeStubs = false
+
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --strict-markers --strict-config"
diff --git a/sdks/code-interpreter/python/src/code_interpreter/__init__.py b/sdks/code-interpreter/python/src/code_interpreter/__init__.py
index 735a3ba6..bbd97d97 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/__init__.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/__init__.py
@@ -21,6 +21,9 @@
session management, and variable persistence across executions.
"""
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as _pkg_version
+
from code_interpreter.code_interpreter import CodeInterpreter
from code_interpreter.models.code import (
CodeContext,
@@ -36,9 +39,6 @@
]
try:
- from importlib.metadata import PackageNotFoundError
- from importlib.metadata import version as _pkg_version
-
__version__ = _pkg_version("opensandbox-code-interpreter")
except PackageNotFoundError: # pragma: no cover
# Fallback for editable/uninstalled source checkouts.
diff --git a/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py b/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py
index ca5cdc57..9cf2c81c 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py
@@ -167,10 +167,76 @@ async def create_context(self, language: str) -> CodeContext:
logger.error("Failed to create context", exc_info=e)
raise ExceptionConverter.to_sandbox_exception(e) from e
+ async def get_context(self, context_id: str) -> CodeContext:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import get_context
+ from opensandbox.api.execd.models.code_context import (
+ CodeContext as ApiCodeContext,
+ )
+
+ client = await self._get_client()
+ response_obj = await get_context.asyncio_detailed(
+ client=client,
+ context_id=context_id,
+ )
+ handle_api_error(response_obj, "Get code context")
+ parsed = require_parsed(response_obj, ApiCodeContext, "Get code context")
+ return CodeExecutionConverter.from_api_code_context(parsed)
+ except Exception as e:
+ logger.error("Failed to get context", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
+ async def list_contexts(self, language: str) -> list[CodeContext]:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import list_contexts
+
+ client = await self._get_client()
+ response_obj = await list_contexts.asyncio_detailed(
+ client=client,
+ language=language,
+ )
+ handle_api_error(response_obj, "List code contexts")
+ parsed_list = require_parsed(response_obj, list, "List code contexts")
+ return [CodeExecutionConverter.from_api_code_context(c) for c in parsed_list]
+ except Exception as e:
+ logger.error("Failed to list contexts", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
+ async def delete_context(self, context_id: str) -> None:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import delete_context
+
+ client = await self._get_client()
+ response_obj = await delete_context.asyncio_detailed(
+ client=client,
+ context_id=context_id,
+ )
+ handle_api_error(response_obj, "Delete code context")
+ except Exception as e:
+ logger.error("Failed to delete context", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
+ async def delete_contexts(self, language: str) -> None:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import (
+ delete_contexts_by_language,
+ )
+
+ client = await self._get_client()
+ response_obj = await delete_contexts_by_language.asyncio_detailed(
+ client=client,
+ language=language,
+ )
+ handle_api_error(response_obj, "Delete code contexts by language")
+ except Exception as e:
+ logger.error("Failed to delete contexts", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
async def run(
self,
code: str,
*,
+ language: str | None = None,
context: CodeContext | None = None,
handlers: ExecutionHandlers | None = None,
) -> Execution:
@@ -184,8 +250,15 @@ async def run(
raise InvalidArgumentException("Code cannot be empty")
try:
- # Default context: ephemeral python context (server-side behavior)
- context = context or CodeContext(language=SupportedLanguage.PYTHON)
+ if context is not None and language is not None and context.language != language:
+ raise InvalidArgumentException(
+ f"language '{language}' must match context.language '{context.language}'"
+ )
+
+ # Default context: language default context (server-side behavior).
+ # When context.id is omitted, execd will create/reuse a default session per language.
+ if context is None:
+ context = CodeContext(language=language or SupportedLanguage.PYTHON)
api_request = CodeExecutionConverter.to_api_run_code_request(code, context)
# Prepare URL
diff --git a/sdks/code-interpreter/python/src/code_interpreter/adapters/converter/code_execution_converter.py b/sdks/code-interpreter/python/src/code_interpreter/adapters/converter/code_execution_converter.py
index 032f45cf..87d3b352 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/adapters/converter/code_execution_converter.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/adapters/converter/code_execution_converter.py
@@ -82,9 +82,9 @@ def from_api_code_context(api_context: ApiCodeContext) -> CodeContext:
Returns:
Domain model code context
"""
- from opensandbox.api.execd.types import UNSET
+ from opensandbox.api.execd.types import Unset
- context_id = api_context.id if api_context.id is not UNSET else None
+ context_id = None if isinstance(api_context.id, Unset) else api_context.id
return CodeContext(
id=context_id,
diff --git a/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py b/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py
index af32ebbc..5c4694ed 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py
@@ -22,7 +22,6 @@
"""
import logging
-from datetime import datetime, timedelta, timezone
from uuid import UUID
from opensandbox.exceptions import (
@@ -30,11 +29,6 @@
SandboxException,
SandboxInternalException,
)
-from opensandbox.models.sandboxes import (
- SandboxEndpoint,
- SandboxInfo,
- SandboxMetrics,
-)
from opensandbox.sandbox import Sandbox
from code_interpreter.adapters.factory import AdapterFactory
@@ -87,8 +81,8 @@ class CodeInterpreter:
)
# Always clean up resources
- await interpreter.kill()
- await interpreter.sandbox.close()
+ await sandbox.kill()
+ await sandbox.close()
```
"""
@@ -176,125 +170,6 @@ def codes(self) -> Codes:
"""
return self._code_service
- async def get_endpoint(self, port: int) -> SandboxEndpoint:
- """
- Gets a specific network endpoint for the underlying sandbox.
-
- This allows access to specific ports exposed by the sandbox, which can be
- useful for connecting to additional services or debugging interfaces.
-
- Args:
- port: The port number to get the endpoint for
-
- Returns:
- Endpoint information including host, port, and connection details
-
- Raises:
- SandboxException: If endpoint cannot be retrieved
- """
- return await self._sandbox.get_endpoint(port)
-
- async def get_info(self) -> SandboxInfo:
- """
- Gets the current status of this sandbox.
-
- Returns:
- Current sandbox status including state and metadata
-
- Raises:
- SandboxException: If status cannot be retrieved
- """
- return await self._sandbox.get_info()
-
- async def get_metrics(self) -> SandboxMetrics:
- """
- Gets the current resource usage metrics for the underlying sandbox.
-
- Provides real-time information about CPU usage, memory consumption,
- disk I/O, and other performance metrics.
-
- Returns:
- Current sandbox metrics including CPU, memory, and I/O statistics
-
- Raises:
- SandboxException: If metrics cannot be retrieved
- """
- return await self._sandbox.get_metrics()
-
- async def renew(self, timeout: timedelta | int) -> None:
- """
- Renew the sandbox expiration time to delay automatic termination.
-
- The new expiration time will be set to the current time plus the provided duration.
-
- Args:
- timeout: Duration to add to the current time to set the new expiration.
- Can be timedelta or seconds as int.
-
- Raises:
- SandboxException: If the operation fails
- """
- if isinstance(timeout, int):
- timeout = timedelta(seconds=timeout)
-
- logger.info(
- "Renew code interpreter %s timeout, estimated expiration to %s",
- self.id,
- datetime.now(timezone.utc) + timeout,
- )
- await self._sandbox.renew(timeout)
-
- async def pause(self) -> None:
- """
- Pauses the sandbox while preserving its state.
-
- The sandbox will transition to PAUSED state and can be resumed later.
- All running processes will be suspended.
-
- Raises:
- SandboxException: If pause operation fails
- """
- logger.info("Pausing code interpreter: %s", self.id)
- await self._sandbox.pause()
-
- async def resume(self) -> None:
- """
- Resumes a previously paused code interpreter.
-
- The sandbox will transition from PAUSED to RUNNING state and all
- suspended processes will be resumed.
-
- Raises:
- SandboxException: If resume operation fails
- """
- logger.info("Resuming code interpreter: %s", self.id)
- await self._sandbox.resume()
-
- async def kill(self) -> None:
- """
- This method sends a termination signal to the remote sandbox instance, causing it to stop immediately.
- This is an irreversible operation.
-
- Note: This method does NOT close the local `Sandbox` object resources (like connection pools).
- You should call `close()` or use async context manager to clean up local resources.
-
- Raises:
- SandboxException: If termination fails
- """
- logger.info("Killing code interpreter: %s", self.id)
- await self._sandbox.kill()
-
- async def is_healthy(self) -> bool:
- """
- Checks if the code interpreter and its underlying sandbox are healthy and responsive.
-
- This performs health checks on both the sandbox infrastructure and code execution services.
-
- Returns:
- True if both sandbox and code execution services are healthy, False otherwise
- """
- return await self._sandbox.is_healthy()
-
@classmethod
async def create(cls, sandbox: Sandbox) -> "CodeInterpreter":
"""
diff --git a/sdks/code-interpreter/python/src/code_interpreter/services/code.py b/sdks/code-interpreter/python/src/code_interpreter/services/code.py
index ae3fc604..018d93ef 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/services/code.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/services/code.py
@@ -20,7 +20,7 @@
session persistence, and real-time execution capabilities.
"""
-from typing import Protocol
+from typing import Protocol, overload
from opensandbox.models.execd import Execution, ExecutionHandlers
@@ -91,10 +91,71 @@ async def create_context(self, language: str) -> CodeContext:
"""
...
+ async def get_context(self, context_id: str) -> CodeContext:
+ """
+ Get an existing execution context by id.
+
+ Args:
+ context_id: Context/session id
+
+ Returns:
+ The existing CodeContext
+ """
+ ...
+
+ async def list_contexts(self, language: str) -> list[CodeContext]:
+ """
+ List active contexts under a given language/runtime.
+
+ Args:
+ language: Execution runtime (e.g. "python", "bash")
+
+ Returns:
+ List of contexts
+ """
+ ...
+
+ async def delete_context(self, context_id: str) -> None:
+ """
+ Delete an execution context by id.
+
+ Args:
+ context_id: Context/session id to delete
+ """
+ ...
+
+ async def delete_contexts(self, language: str) -> None:
+ """
+ Delete all execution contexts under a given language/runtime.
+
+ Args:
+ language: Execution runtime (e.g. "python", "bash")
+ """
+ ...
+
+ @overload
+ async def run(
+ self,
+ code: str,
+ *,
+ context: CodeContext,
+ handlers: ExecutionHandlers | None = None,
+ ) -> Execution: ...
+
+ @overload
+ async def run(
+ self,
+ code: str,
+ *,
+ language: str,
+ handlers: ExecutionHandlers | None = None,
+ ) -> Execution: ...
+
async def run(
self,
code: str,
*,
+ language: str | None = None,
context: CodeContext | None = None,
handlers: ExecutionHandlers | None = None,
) -> Execution:
@@ -115,7 +176,11 @@ async def run(
Args:
code: Source code to execute.
- context: Execution context (language + optional id). If None, a temporary Python context is used.
+ language: Convenience language selector for this run. If provided and ``context`` is None,
+ a **default context for this language** is used (execd will create/reuse a default
+ session when ``context.id`` is omitted). If both ``language`` and ``context`` are
+ provided, they must match.
+ context: Execution context (language + optional id). If None, the default Python context is used.
handlers: Optional streaming handlers for stdout/stderr/events.
Returns:
diff --git a/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py b/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py
index 397cc57e..30472003 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py
@@ -114,7 +114,7 @@ def create_context(self, language: str) -> CodeContextSync:
from opensandbox.api.execd.models.code_context_request import (
CodeContextRequest,
)
- from opensandbox.api.execd.types import UNSET
+ from opensandbox.api.execd.types import Unset
response_obj = create_code_context.sync_detailed(
client=self._client,
@@ -122,16 +122,86 @@ def create_context(self, language: str) -> CodeContextSync:
)
handle_api_error(response_obj, "Create code context")
parsed = require_parsed(response_obj, ApiCodeContext, "Create code context")
- context_id = parsed.id if parsed.id is not UNSET else None
+ context_id = None if isinstance(parsed.id, Unset) else parsed.id
return CodeContextSync(id=context_id, language=parsed.language)
except Exception as e:
logger.error("Failed to create context", exc_info=e)
raise ExceptionConverter.to_sandbox_exception(e) from e
+ def get_context(self, context_id: str) -> CodeContextSync:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import get_context
+ from opensandbox.api.execd.models.code_context import (
+ CodeContext as ApiCodeContext,
+ )
+ from opensandbox.api.execd.types import Unset
+
+ response_obj = get_context.sync_detailed(
+ client=self._client,
+ context_id=context_id,
+ )
+ handle_api_error(response_obj, "Get code context")
+ parsed = require_parsed(response_obj, ApiCodeContext, "Get code context")
+ context_id_val = None if isinstance(parsed.id, Unset) else parsed.id
+ return CodeContextSync(id=context_id_val, language=parsed.language)
+ except Exception as e:
+ logger.error("Failed to get context", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
+ def list_contexts(self, language: str) -> list[CodeContextSync]:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import list_contexts
+ from opensandbox.api.execd.types import UNSET
+
+ response_obj = list_contexts.sync_detailed(
+ client=self._client,
+ language=language,
+ )
+ handle_api_error(response_obj, "List code contexts")
+ parsed_list = require_parsed(response_obj, list, "List code contexts")
+ result: list[CodeContextSync] = []
+ for c in parsed_list:
+ # c is an API CodeContext model
+ context_id_val = c.id if c.id is not UNSET else None
+ result.append(CodeContextSync(id=context_id_val, language=c.language))
+ return result
+ except Exception as e:
+ logger.error("Failed to list contexts", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
+ def delete_context(self, context_id: str) -> None:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import delete_context
+
+ response_obj = delete_context.sync_detailed(
+ client=self._client,
+ context_id=context_id,
+ )
+ handle_api_error(response_obj, "Delete code context")
+ except Exception as e:
+ logger.error("Failed to delete context", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
+ def delete_contexts(self, language: str) -> None:
+ try:
+ from opensandbox.api.execd.api.code_interpreting import (
+ delete_contexts_by_language,
+ )
+
+ response_obj = delete_contexts_by_language.sync_detailed(
+ client=self._client,
+ language=language,
+ )
+ handle_api_error(response_obj, "Delete code contexts by language")
+ except Exception as e:
+ logger.error("Failed to delete contexts", exc_info=e)
+ raise ExceptionConverter.to_sandbox_exception(e) from e
+
def run(
self,
code: str,
*,
+ language: str | None = None,
context: CodeContextSync | None = None,
handlers: ExecutionHandlersSync | None = None,
) -> Execution:
@@ -155,7 +225,15 @@ def run(
raise InvalidArgumentException("Code cannot be empty")
try:
- context = context or CodeContextSync(language=SupportedLanguageSync.PYTHON)
+ if context is not None and language is not None and context.language != language:
+ raise InvalidArgumentException(
+ f"language '{language}' must match context.language '{context.language}'"
+ )
+
+ if context is None:
+ # Default context: language default context (server-side behavior).
+ # When context.id is omitted, execd will create/reuse a default session per language.
+ context = CodeContextSync(language=language or SupportedLanguageSync.PYTHON)
api_request = {
"code": code,
"context": {
diff --git a/sdks/code-interpreter/python/src/code_interpreter/sync/code_interpreter.py b/sdks/code-interpreter/python/src/code_interpreter/sync/code_interpreter.py
index c6b2859b..2e4f6753 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/sync/code_interpreter.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/sync/code_interpreter.py
@@ -18,7 +18,6 @@
"""
import logging
-from datetime import datetime, timedelta, timezone
from uuid import UUID
from opensandbox.constants import DEFAULT_EXECD_PORT
@@ -27,11 +26,6 @@
SandboxException,
SandboxInternalException,
)
-from opensandbox.models.sandboxes import (
- SandboxEndpoint,
- SandboxInfo,
- SandboxMetrics,
-)
from opensandbox.sync.sandbox import SandboxSync
from code_interpreter.sync.adapters.factory import AdapterFactorySync
@@ -54,8 +48,8 @@ class CodeInterpreterSync:
- **Blocking**: Do not call these methods directly from an asyncio event loop thread.
If you need non-blocking behavior, prefer the async :class:`~code_interpreter.code_interpreter.CodeInterpreter`.
- - **Lifecycle**: Remote lifecycle is owned by the underlying sandbox. This class delegates
- pause/resume/kill/renew/metrics to the sandbox.
+ - **Lifecycle**: Remote lifecycle is owned by the underlying sandbox; call methods on
+ ``interpreter.sandbox`` for pause/resume/kill/renew/metrics/info/endpoints.
Usage Example:
@@ -153,107 +147,6 @@ def codes(self) -> CodesSync:
"""
return self._code_service
- def get_endpoint(self, port: int) -> SandboxEndpoint:
- """
- Gets a specific network endpoint for the underlying sandbox.
-
- Args:
- port: The port number to get the endpoint for
-
- Returns:
- Endpoint information including host, port, and connection details
-
- Raises:
- SandboxException: If endpoint cannot be retrieved
- """
- return self._sandbox.get_endpoint(port)
-
- def get_info(self) -> SandboxInfo:
- """
- Gets the current status of this sandbox.
-
- Returns:
- Current sandbox status including state and metadata
-
- Raises:
- SandboxException: If status cannot be retrieved
- """
- return self._sandbox.get_info()
-
- def get_metrics(self) -> SandboxMetrics:
- """
- Gets the current resource usage metrics for the underlying sandbox.
-
- Returns:
- Current sandbox metrics including CPU, memory, and I/O statistics
-
- Raises:
- SandboxException: If metrics cannot be retrieved
- """
- return self._sandbox.get_metrics()
-
- def renew(self, timeout: timedelta | int) -> None:
- """
- Renew the sandbox expiration time to delay automatic termination.
-
- Args:
- timeout: Duration to add to the current time to set the new expiration.
- Can be timedelta or seconds as int.
-
- Raises:
- SandboxException: If the operation fails
- """
- if isinstance(timeout, int):
- timeout = timedelta(seconds=timeout)
- logger.info(
- "Renew code interpreter %s timeout, estimated expiration to %s",
- self.id,
- datetime.now(timezone.utc) + timeout,
- )
- self._sandbox.renew(timeout)
-
- def pause(self) -> None:
- """
- Pauses the sandbox while preserving its state.
-
- Raises:
- SandboxException: If pause operation fails
- """
- logger.info("Pausing code interpreter: %s", self.id)
- self._sandbox.pause()
-
- def resume(self) -> None:
- """
- Resumes a previously paused sandbox.
-
- Raises:
- SandboxException: If resume operation fails
- """
- logger.info("Resuming code interpreter: %s", self.id)
- self._sandbox.resume()
-
- def kill(self) -> None:
- """
- Terminate the remote sandbox instance (irreversible).
-
- Note: This method does NOT close the local `SandboxSync` object resources (like connection pools).
- You should call `sandbox().close()` or use the sync context manager on the sandbox to clean up.
-
- Raises:
- SandboxException: If termination fails
- """
- logger.info("Killing code interpreter: %s", self.id)
- self._sandbox.kill()
-
- def is_healthy(self) -> bool:
- """
- Checks if the code interpreter and its underlying sandbox are healthy and responsive.
-
- Returns:
- True if sandbox is healthy, False otherwise
- """
- return self._sandbox.is_healthy()
-
@classmethod
def create(cls, sandbox: SandboxSync) -> "CodeInterpreterSync":
"""
diff --git a/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py b/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py
index 639edf99..7715333a 100644
--- a/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py
+++ b/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py
@@ -22,7 +22,7 @@
This is the sync counterpart of :mod:`code_interpreter.services.code`.
"""
-from typing import Protocol
+from typing import Protocol, overload
from opensandbox.models.execd import Execution
from opensandbox.models.execd_sync import ExecutionHandlersSync
@@ -73,10 +73,45 @@ def create_context(self, language: str) -> CodeContextSync:
"""
...
+ def get_context(self, context_id: str) -> CodeContextSync:
+ """Get an existing execution context by id (blocking)."""
+ ...
+
+ def list_contexts(self, language: str) -> list[CodeContextSync]:
+ """List active contexts under a given language/runtime (blocking)."""
+ ...
+
+ def delete_context(self, context_id: str) -> None:
+ """Delete an execution context by id (blocking)."""
+ ...
+
+ def delete_contexts(self, language: str) -> None:
+ """Delete all contexts under a language/runtime (blocking)."""
+ ...
+
+ @overload
+ def run(
+ self,
+ code: str,
+ *,
+ context: CodeContextSync,
+ handlers: ExecutionHandlersSync | None = None,
+ ) -> Execution: ...
+
+ @overload
+ def run(
+ self,
+ code: str,
+ *,
+ language: str,
+ handlers: ExecutionHandlersSync | None = None,
+ ) -> Execution: ...
+
def run(
self,
code: str,
*,
+ language: str | None = None,
context: CodeContextSync | None = None,
handlers: ExecutionHandlersSync | None = None,
) -> Execution:
@@ -95,7 +130,11 @@ def run(
Args:
code: Source code to execute.
- context: Execution context (language + optional id). If None, a temporary Python context is used.
+ language: Convenience language selector for this run. If provided and ``context`` is None,
+ a **default context for this language** is used (execd will create/reuse a default
+ session when ``context.id`` is omitted). If both ``language`` and ``context`` are
+ provided, they must match.
+ context: Execution context (language + optional id). If None, the default Python context is used.
handlers: Optional streaming handlers for stdout/stderr/events.
Returns:
diff --git a/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py b/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py
index cf8652a2..d6680e9f 100644
--- a/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py
+++ b/sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py
@@ -43,6 +43,15 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
)
return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=sse, request=request)
+ if request.url.path == "/code" and payload.get("code") == "print(2)":
+ assert payload["context"]["language"] == "go"
+ sse = (
+ b'data: {"type":"init","text":"exec-2","timestamp":1}\n\n'
+ b'data: {"type":"stdout","text":"2\\n","timestamp":2}\n\n'
+ b'data: {"type":"execution_complete","timestamp":3,"execution_time":7}\n\n'
+ )
+ return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=sse, request=request)
+
return httpx.Response(400, content=b"bad", request=request)
@@ -65,6 +74,17 @@ async def test_run_code_streaming_happy_path_updates_execution() -> None:
assert execution.logs.stdout[0].text == "1\n"
+@pytest.mark.asyncio
+async def test_run_code_can_accept_language_string_without_context() -> None:
+ cfg = ConnectionConfig(protocol="http", transport=_SseTransport())
+ endpoint = SandboxEndpoint(endpoint="localhost:44772", port=44772)
+ adapter = CodesAdapter(endpoint, cfg)
+
+ execution = await adapter.run("print(2)", language=SupportedLanguage.GO)
+ assert execution.id == "exec-2"
+ assert execution.logs.stdout[0].text == "2\n"
+
+
@pytest.mark.asyncio
async def test_run_code_rejects_blank_code() -> None:
cfg = ConnectionConfig(protocol="http")
@@ -75,6 +95,20 @@ async def test_run_code_rejects_blank_code() -> None:
await adapter.run(" ")
+@pytest.mark.asyncio
+async def test_run_code_rejects_mismatched_language_and_context() -> None:
+ cfg = ConnectionConfig(protocol="http", transport=_SseTransport())
+ endpoint = SandboxEndpoint(endpoint="localhost:44772", port=44772)
+ adapter = CodesAdapter(endpoint, cfg)
+
+ with pytest.raises(InvalidArgumentException):
+ await adapter.run(
+ "print(1)",
+ context=CodeContext(language=SupportedLanguage.PYTHON),
+ language=SupportedLanguage.GO,
+ )
+
+
@pytest.mark.asyncio
async def test_run_code_non_200_raises_api_exception() -> None:
cfg = ConnectionConfig(protocol="http", transport=_SseTransport())
diff --git a/sdks/sandbox/kotlin/gradle.properties b/sdks/sandbox/kotlin/gradle.properties
index 8c185259..7d8bb62d 100644
--- a/sdks/sandbox/kotlin/gradle.properties
+++ b/sdks/sandbox/kotlin/gradle.properties
@@ -5,5 +5,5 @@ org.gradle.parallel=true
# Project metadata
project.group=com.alibaba.opensandbox
-project.version=1.0.0
+project.version=1.0.1
project.description=A Kotlin SDK for Open Sandbox API
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/HttpClientProvider.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/HttpClientProvider.kt
index d9cecd2d..57bc244e 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/HttpClientProvider.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/HttpClientProvider.kt
@@ -29,7 +29,7 @@ import java.util.concurrent.TimeUnit
*/
class HttpClientProvider(
val config: ConnectionConfig,
-) {
+) : AutoCloseable {
private val logger = LoggerFactory.getLogger(HttpClientProvider::class.java)
private val baseBuilder: OkHttpClient.Builder
@@ -149,7 +149,7 @@ class HttpClientProvider(
/**
* Closes the underlying HTTP client and releases resources.
*/
- fun close() {
+ override fun close() {
// Now we can pass the specific backing fields to check initialization
shutdownClientQuietly(httpClientLazy, "http client")
shutdownClientQuietly(authenticatedClientLazy, "authenticated client")
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt
index 583a3249..79980a62 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt
@@ -21,12 +21,12 @@ import com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentExceptio
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxInternalException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException
-import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxUnhealthyException
import com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EXECD_PORT
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.services.Commands
import com.alibaba.opensandbox.sandbox.domain.services.Filesystem
import com.alibaba.opensandbox.sandbox.domain.services.Health
@@ -140,58 +140,55 @@ class Sandbox internal constructor(
@JvmStatic
fun connector(): Connector = Connector()
+ @JvmStatic
+ fun resumer(): Resumer = Resumer()
+
/**
- * Creates a sandbox instance with the provided configuration.
+ * Initialization result indicating the type of sandbox being initialized.
+ */
+ private sealed class InitializationResult {
+ abstract val id: UUID
+
+ data class NewSandbox(override val id: UUID) : InitializationResult()
+
+ data class ExistingSandbox(override val id: UUID) : InitializationResult()
+ }
+
+ /**
+ * Common initialization logic for create, connect, and resume operations.
*
- * @param imageSpec Container image specification
- * @param entrypoint Sandbox entrypoint command
- * @param env Environment variables (optional)
- * @param metadata Metadata for the sandbox (optional)
- * @param timeout Sandbox timeout (automatic termination time)
- * @param readyTimeout Timeout for waiting for sandbox readiness
- * @param resource Resource limits (optional)
+ * @param operationName Operation name for logging
* @param connectionConfig Connection configuration
- * @param healthCheck Custom health check function (optional)
- * @param healthCheckPollingInterval Polling interval for readiness/health check
- * @param extensions Optional extension parameters for server-side customized behaviors
- * @return Fully configured and ready Sandbox instance
- * @throws SandboxException if sandbox creation or initialization fails
+ * @param healthCheck Custom health check function
+ * @param timeout Timeout for readiness check
+ * @param healthCheckPollingInterval Polling interval for health check
+ * @param initAction Initialization action that returns the sandbox ID and type
+ * @return Fully initialized Sandbox instance
+ * @throws SandboxException if initialization fails
*/
- private fun create(
- imageSpec: SandboxImageSpec,
- entrypoint: List,
- env: Map,
- metadata: Map,
- timeout: Duration,
- readyTimeout: Duration,
- resource: Map,
+ private fun initializeSandbox(
+ operationName: String,
connectionConfig: ConnectionConfig,
- healthCheck: ((Sandbox) -> Boolean)? = null,
+ healthCheck: ((Sandbox) -> Boolean)?,
+ timeout: Duration,
healthCheckPollingInterval: Duration,
- extensions: Map,
+ skipHealthCheck: Boolean,
+ initAction: (Sandboxes) -> InitializationResult,
): Sandbox {
- logger.info("Start creating sandbox with image: {} (timeout: {}s)", imageSpec.image, timeout.seconds)
+ logger.info("Starting {} operation", operationName)
val httpClientProvider = HttpClientProvider(connectionConfig)
val factory = AdapterFactory(httpClientProvider)
- var sandboxId: UUID? = null
+ var initResult: InitializationResult? = null
var sandboxService: Sandboxes? = null
try {
sandboxService = factory.createSandboxes()
- val response =
- sandboxService.createSandbox(
- imageSpec,
- entrypoint,
- env,
- metadata,
- timeout,
- resource,
- extensions,
- )
- sandboxId = response.id
+ initResult = initAction(sandboxService)
+
+ val sandboxId = initResult.id
- val execdEndpoint = sandboxService.getSandboxEndpoint(response.id, DEFAULT_EXECD_PORT)
+ val execdEndpoint = sandboxService.getSandboxEndpoint(sandboxId, DEFAULT_EXECD_PORT)
val fileSystemService = factory.createFilesystem(execdEndpoint)
val commandService = factory.createCommands(execdEndpoint)
val metricsService = factory.createMetrics(execdEndpoint)
@@ -199,7 +196,7 @@ class Sandbox internal constructor(
val sandbox =
Sandbox(
- id = response.id,
+ id = sandboxId,
sandboxService = sandboxService,
fileSystemService = fileSystemService,
commandService = commandService,
@@ -209,19 +206,28 @@ class Sandbox internal constructor(
httpClientProvider = httpClientProvider,
)
- sandbox.checkReady(readyTimeout, healthCheckPollingInterval)
-
- logger.info("Sandbox {} is ready and available for use", sandbox.id)
+ if (!skipHealthCheck) {
+ sandbox.checkReady(timeout, healthCheckPollingInterval)
+ logger.info("{} operation completed for sandbox {}", operationName, sandboxId)
+ } else {
+ logger.info(
+ "{} operation completed for sandbox {} (skipHealthCheck=true, sandbox may not be ready yet)",
+ operationName,
+ sandboxId,
+ )
+ }
return sandbox
} catch (e: Exception) {
- // Attempt cleanup of remote resource if creation was successful but initialization failed
- if (sandboxId != null && sandboxService != null) {
+ if (initResult is InitializationResult.NewSandbox && sandboxService != null) {
try {
- logger.warn("Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: {}", sandboxId)
- sandboxService.killSandbox(sandboxId)
+ logger.warn(
+ "Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: {}",
+ initResult.id,
+ )
+ sandboxService.killSandbox(initResult.id)
} catch (cleanupEx: Exception) {
- logger.error("Failed to clean up sandbox {} after creation failure", sandboxId, cleanupEx)
+ logger.error("Failed to clean up sandbox {} after creation failure", initResult.id, cleanupEx)
e.addSuppressed(cleanupEx)
}
}
@@ -230,9 +236,9 @@ class Sandbox internal constructor(
when (e) {
is SandboxException -> throw e
else -> {
- logger.error("Unexpected exception during sandbox creation", e)
+ logger.error("Unexpected exception during {}", operationName, e)
throw SandboxInternalException(
- message = "Internal exception when creating sandbox: ${e.message}",
+ message = "Failed to $operationName: ${e.message}",
cause = e,
)
}
@@ -240,6 +246,59 @@ class Sandbox internal constructor(
}
}
+ /**
+ * Creates a sandbox instance with the provided configuration.
+ *
+ * @param imageSpec Container image specification
+ * @param entrypoint Sandbox entrypoint command
+ * @param env Environment variables (optional)
+ * @param metadata Metadata for the sandbox (optional)
+ * @param timeout Sandbox timeout (automatic termination time)
+ * @param readyTimeout Timeout for waiting for sandbox readiness
+ * @param resource Resource limits (optional)
+ * @param connectionConfig Connection configuration
+ * @param healthCheck Custom health check function (optional)
+ * @param healthCheckPollingInterval Polling interval for readiness/health check
+ * @param extensions Optional extension parameters for server-side customized behaviors
+ * @return Fully configured and ready Sandbox instance
+ * @throws SandboxException if sandbox creation or initialization fails
+ */
+ private fun create(
+ imageSpec: SandboxImageSpec,
+ entrypoint: List,
+ env: Map,
+ metadata: Map,
+ timeout: Duration,
+ readyTimeout: Duration,
+ resource: Map,
+ connectionConfig: ConnectionConfig,
+ healthCheck: ((Sandbox) -> Boolean)? = null,
+ healthCheckPollingInterval: Duration,
+ extensions: Map,
+ skipHealthCheck: Boolean,
+ ): Sandbox {
+ return initializeSandbox(
+ operationName = "create sandbox with image ${imageSpec.image} (timeout: ${timeout.seconds}s)",
+ connectionConfig = connectionConfig,
+ healthCheck = healthCheck,
+ timeout = readyTimeout,
+ healthCheckPollingInterval = healthCheckPollingInterval,
+ skipHealthCheck = skipHealthCheck,
+ ) { sandboxService ->
+ val response =
+ sandboxService.createSandbox(
+ imageSpec,
+ entrypoint,
+ env,
+ metadata,
+ timeout,
+ resource,
+ extensions,
+ )
+ InitializationResult.NewSandbox(response.id)
+ }
+ }
+
/**
* Connects to an existing sandbox instance by ID.
*
@@ -254,53 +313,57 @@ class Sandbox internal constructor(
sandboxId: UUID,
connectionConfig: ConnectionConfig,
healthCheck: ((Sandbox) -> Boolean)? = null,
+ connectTimeout: Duration,
+ healthCheckPollingInterval: Duration,
+ skipHealthCheck: Boolean,
): Sandbox {
- logger.info("Connecting to existing sandbox: {}", sandboxId)
- val httpClientProvider = HttpClientProvider(connectionConfig)
- val factory = AdapterFactory(httpClientProvider)
-
- try {
- val sandboxService = factory.createSandboxes()
-
- val execdEndpoint = sandboxService.getSandboxEndpoint(sandboxId, DEFAULT_EXECD_PORT)
- val fileSystemService = factory.createFilesystem(execdEndpoint)
- val commandService = factory.createCommands(execdEndpoint)
- val metricsService = factory.createMetrics(execdEndpoint)
- val healthService = factory.createHealth(execdEndpoint)
-
- val sandbox =
- Sandbox(
- id = sandboxId,
- sandboxService = sandboxService,
- fileSystemService = fileSystemService,
- commandService = commandService,
- metricsService = metricsService,
- healthService = healthService,
- customHealthCheck = healthCheck,
- httpClientProvider = httpClientProvider,
- )
-
- if (!sandbox.isHealthy()) {
- throw SandboxUnhealthyException(
- message = "Failed to connect unhealthy sandbox $sandboxId",
- )
- }
-
- logger.info("Sandbox {} connected", sandbox.id)
+ return initializeSandbox(
+ operationName = "connect to sandbox $sandboxId",
+ connectionConfig = connectionConfig,
+ healthCheck = healthCheck,
+ timeout = connectTimeout,
+ healthCheckPollingInterval = healthCheckPollingInterval,
+ skipHealthCheck = skipHealthCheck,
+ ) { _ ->
+ InitializationResult.ExistingSandbox(sandboxId)
+ }
+ }
- return sandbox
- } catch (e: Exception) {
- httpClientProvider.close()
- when (e) {
- is SandboxException -> throw e
- else -> {
- logger.error("Unexpected exception during sandbox connection", e)
- throw SandboxInternalException(
- message = "Failed to connect to sandbox: ${e.message}",
- cause = e,
- )
- }
- }
+ /**
+ * Resumes a paused sandbox and waits until it becomes healthy.
+ *
+ * This method performs the following steps:
+ * 1. Calls the server-side resume operation to transition the sandbox back to RUNNING.
+ * 2. Re-resolves the execd endpoint (it may change across pause/resume on some backends).
+ * 3. Rebuilds service adapters bound to the endpoint.
+ * 4. Waits for readiness/health with polling until [resumeTimeout] elapses.
+ *
+ * @param sandboxId Sandbox ID to resume
+ * @param connectionConfig Connection configuration
+ * @param healthCheck Optional custom health check; falls back to [Sandbox.ping]
+ * @param resumeTimeout Max time to wait for the sandbox to become ready after resuming
+ * @param healthCheckPollingInterval Polling interval for readiness/health check
+ * @return Resumed and ready Sandbox instance
+ * @throws SandboxException if resume or readiness check fails
+ */
+ private fun resume(
+ sandboxId: UUID,
+ connectionConfig: ConnectionConfig,
+ healthCheck: ((Sandbox) -> Boolean)? = null,
+ resumeTimeout: Duration,
+ healthCheckPollingInterval: Duration,
+ skipHealthCheck: Boolean,
+ ): Sandbox {
+ return initializeSandbox(
+ operationName = "resume sandbox $sandboxId",
+ connectionConfig = connectionConfig,
+ healthCheck = healthCheck,
+ timeout = resumeTimeout,
+ healthCheckPollingInterval = healthCheckPollingInterval,
+ skipHealthCheck = skipHealthCheck,
+ ) { sandboxService ->
+ sandboxService.resumeSandbox(sandboxId)
+ InitializationResult.ExistingSandbox(sandboxId)
}
}
}
@@ -342,9 +405,9 @@ class Sandbox internal constructor(
* @param timeout Duration to add to the current time to set the new expiration
* @throws SandboxException if the operation fails
*/
- fun renew(timeout: Duration) {
+ fun renew(timeout: Duration): SandboxRenewResponse {
logger.info("Renew sandbox {} timeout, estimated expiration to {}", id, OffsetDateTime.now().plus(timeout))
- sandboxService.renewSandboxExpiration(id, OffsetDateTime.now().plus(timeout))
+ return sandboxService.renewSandboxExpiration(id, OffsetDateTime.now().plus(timeout))
}
/**
@@ -360,19 +423,6 @@ class Sandbox internal constructor(
sandboxService.pauseSandbox(id)
}
- /**
- * Resumes a previously paused sandbox.
- *
- * The sandbox will transition from PAUSED to RUNNING state and all
- * suspended processes will be resumed.
- *
- * @throws SandboxException if resume operation fails
- */
- fun resume() {
- logger.info("Resuming sandbox: {}", id)
- sandboxService.resumeSandbox(id)
- }
-
/**
* This method sends a termination signal to the remote sandbox instance, causing it to stop immediately.
* This is an irreversible operation.
@@ -473,11 +523,11 @@ class Sandbox internal constructor(
}
/**
- * Checks if the sandbox is alive.
+ * Ping execd
*
- * @return `true` if the sandbox is healthy.
+ * @return `true` if execd is reachable and healthy.
*/
- private fun ping(): Boolean {
+ fun ping(): Boolean {
return healthService.ping(id)
}
@@ -523,6 +573,23 @@ class Sandbox internal constructor(
*/
private var healthCheck: ((Sandbox) -> Boolean)? = null
+ /**
+ * Max time to wait for the sandbox to become ready after connecting
+ */
+ private var connectTimeout: Duration = Duration.ofSeconds(30)
+
+ /**
+ * Polling interval for readiness/health check while waiting for resume
+ */
+ private var healthCheckPollingInterval: Duration = Duration.ofMillis(200)
+
+ /**
+ * When true, do NOT wait for sandbox readiness/health during [connect].
+ *
+ * Default is false (wait until ready).
+ */
+ private var skipHealthCheck: Boolean = false
+
/**
* Sets the sandbox ID to connect to.
*
@@ -545,6 +612,30 @@ class Sandbox internal constructor(
return this
}
+ /**
+ * Sets the max time to wait for readiness after the connect operation.
+ */
+ fun connectTimeout(timeout: Duration): Connector {
+ this.connectTimeout = timeout
+ return this
+ }
+
+ /**
+ * Sets the polling interval used while waiting for readiness after connecting.
+ */
+ fun healthCheckPollingInterval(pollingInterval: Duration): Connector {
+ this.healthCheckPollingInterval = pollingInterval
+ return this
+ }
+
+ /**
+ * Skip readiness/health check during [connect]. The returned sandbox may not be ready yet.
+ */
+ fun skipHealthCheck(skip: Boolean = true): Connector {
+ this.skipHealthCheck = skip
+ return this
+ }
+
/**
* Connects to the existing sandbox with the configured parameters.
*
@@ -567,6 +658,9 @@ class Sandbox internal constructor(
sandboxId = id,
connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),
healthCheck = healthCheck,
+ connectTimeout = connectTimeout,
+ healthCheckPollingInterval = healthCheckPollingInterval,
+ skipHealthCheck = skipHealthCheck,
)
}
}
@@ -651,6 +745,13 @@ class Sandbox internal constructor(
private var healthCheckPollingInterval: Duration = Duration.ofMillis(200)
private var healthCheck: ((Sandbox) -> Boolean)? = null
+ /**
+ * When true, do NOT wait for sandbox readiness/health during [build].
+ *
+ * Default is false (wait until ready).
+ */
+ private var skipHealthCheck: Boolean = false
+
/**
* Connection config
*/
@@ -913,6 +1014,14 @@ class Sandbox internal constructor(
return this
}
+ /**
+ * Skip readiness/health check during [build]. The returned sandbox may not be ready yet.
+ */
+ fun skipHealthCheck(skip: Boolean = true): Builder {
+ this.skipHealthCheck = skip
+ return this
+ }
+
fun connectionConfig(connectionConfig: ConnectionConfig): Builder {
this.connectionConfig = connectionConfig
return this
@@ -954,6 +1063,146 @@ class Sandbox internal constructor(
connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),
healthCheckPollingInterval = healthCheckPollingInterval,
healthCheck = healthCheck,
+ skipHealthCheck = skipHealthCheck,
+ )
+ }
+ }
+
+ /**
+ * Fluent resumer for resuming paused sandbox instances.
+ *
+ * This class provides a type-safe, fluent interface for configuring connection parameters
+ * and readiness behavior when resuming an existing sandbox.
+ *
+ * ## Basic Usage
+ *
+ * ```kotlin
+ * val sandbox = Sandbox.resumer()
+ * .sandboxId(existingSandboxId)
+ * .resume()
+ * ```
+ *
+ * ## Advanced Configuration
+ *
+ * ```kotlin
+ * val sandbox = Sandbox.resumer()
+ * .sandboxId(existingSandboxId)
+ * .connectionConfig(ConnectionConfig.builder().apiKey("...").build())
+ * .resumeTimeout(Duration.ofSeconds(60))
+ * .healthCheckPollingInterval(Duration.ofMillis(200))
+ * .healthCheck { it.isHealthy() }
+ * .resume()
+ * ```
+ */
+ class Resumer internal constructor() {
+ /**
+ * Sandbox ID to resume
+ */
+ private var sandboxId: UUID? = null
+
+ /**
+ * Connection config
+ */
+ private var connectionConfig: ConnectionConfig? = null
+
+ /**
+ * Health check logic
+ */
+ private var healthCheck: ((Sandbox) -> Boolean)? = null
+
+ /**
+ * Max time to wait for the sandbox to become ready after resuming
+ */
+ private var resumeTimeout: Duration = Duration.ofSeconds(30)
+
+ /**
+ * Polling interval for readiness/health check while waiting for resume
+ */
+ private var healthCheckPollingInterval: Duration = Duration.ofMillis(200)
+
+ /**
+ * When true, do NOT wait for sandbox readiness/health during [resume].
+ *
+ * Default is false (wait until ready).
+ */
+ private var skipHealthCheck: Boolean = false
+
+ /**
+ * Sets the sandbox ID to resume.
+ *
+ * @param sandboxId UUID of the paused sandbox
+ * @return This resumer for method chaining
+ */
+ fun sandboxId(sandboxId: UUID): Resumer {
+ this.sandboxId = sandboxId
+ return this
+ }
+
+ /**
+ * Sets a custom health check used by [Sandbox.checkReady] after resuming.
+ *
+ * If not set, [Sandbox.ping] will be used.
+ */
+ fun healthCheck(healthCheck: (Sandbox) -> Boolean): Resumer {
+ this.healthCheck = healthCheck
+ return this
+ }
+
+ /**
+ * Sets the connection configuration used to talk to the Open Sandbox API.
+ */
+ fun connectionConfig(connectionConfig: ConnectionConfig): Resumer {
+ this.connectionConfig = connectionConfig
+ return this
+ }
+
+ /**
+ * Sets the max time to wait for readiness after the resume operation.
+ */
+ fun resumeTimeout(timeout: Duration): Resumer {
+ this.resumeTimeout = timeout
+ return this
+ }
+
+ /**
+ * Sets the polling interval used while waiting for readiness after resuming.
+ */
+ fun healthCheckPollingInterval(pollingInterval: Duration): Resumer {
+ this.healthCheckPollingInterval = pollingInterval
+ return this
+ }
+
+ /**
+ * Skip readiness/health check during [resume]. The returned sandbox may not be ready yet.
+ */
+ fun skipHealthCheck(skip: Boolean = true): Resumer {
+ this.skipHealthCheck = skip
+ return this
+ }
+
+ /**
+ * Resumes the sandbox with the configured parameters.
+ *
+ * This method validates required configuration, performs the server-side resume,
+ * rebuilds service adapters, and waits for readiness.
+ *
+ * @return Resumed and ready Sandbox instance
+ * @throws InvalidArgumentException if sandboxId is missing
+ * @throws SandboxException if resume or readiness check fails
+ */
+ fun resume(): Sandbox {
+ val id =
+ sandboxId ?: throw InvalidArgumentException(
+ message = "Sandbox ID must be specified",
+ )
+
+ return resume(
+ sandboxId = id,
+ connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),
+ healthCheck = healthCheck,
+ resumeTimeout = resumeTimeout,
+ healthCheckPollingInterval = healthCheckPollingInterval,
+ skipHealthCheck = skipHealthCheck,
)
}
}
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt
index 2dcedce6..4b253dbd 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt
@@ -22,6 +22,7 @@ import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import com.alibaba.opensandbox.sandbox.infrastructure.factory.AdapterFactory
import org.slf4j.LoggerFactory
@@ -133,9 +134,9 @@ class SandboxManager internal constructor(
fun renewSandbox(
sandboxId: UUID,
timeout: Duration,
- ) {
+ ): SandboxRenewResponse {
logger.info("Renew expiration for sandbox {} to {}", sandboxId, OffsetDateTime.now().plus(timeout))
- sandboxService.renewSandboxExpiration(sandboxId, OffsetDateTime.now().plus(timeout))
+ return sandboxService.renewSandboxExpiration(sandboxId, OffsetDateTime.now().plus(timeout))
}
/**
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt
index fdcbb1f4..42d2a8f3 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt
@@ -49,7 +49,7 @@ class ConnectionConfig private constructor(
private const val ENV_API_KEY = "OPEN_SANDBOX_API_KEY"
private const val ENV_DOMAIN = "OPEN_SANDBOX_DOMAIN"
- private const val DEFAULT_USER_AGENT = "OpenSandbox-Kotlin-SDK/1.0.0"
+ private const val DEFAULT_USER_AGENT = "OpenSandbox-Kotlin-SDK/1.0.1"
@JvmStatic
fun builder(): Builder = Builder()
@@ -74,23 +74,64 @@ class ConnectionConfig private constructor(
/**
* Builder for [ConnectionConfig].
+ *
+ * This builder is part of the public SDK surface and is intended to be used directly by end users.
+ *
+ * ### Defaults & environment variables
+ * - If `apiKey` is not provided, the SDK will read it from environment variable `OPEN_SANDBOX_API_KEY`.
+ * - If `domain` is not provided, the SDK will read it from environment variable `OPEN_SANDBOX_DOMAIN`,
+ * falling back to `localhost:8080`.
+ *
+ * ### Lifecycle / resource ownership
+ * - If you do **not** provide a custom [ConnectionPool], the SDK creates and owns a default one.
+ * Calling `Sandbox.close()` / `SandboxManager.close()` will close SDK-owned HTTP clients and
+ * release the SDK-owned connection pool.
+ * - If you **do** provide a [ConnectionPool] via [connectionPool], it is treated as user-owned
+ * and will **not** be evicted by the SDK on close.
+ *
+ * ### Notes
+ * - `domain` may include a scheme (e.g. `https://example.com`); in that case the SDK will use it
+ * as-is and ignore [protocol] when constructing the base URL.
*/
class Builder internal constructor() {
private var apiKey: String? = null
+
private var domain: String? = null
+
private var protocol: String = DEFAULT_PROTOCOL
+
private var requestTimeout: Duration = Duration.ofSeconds(30)
+
private var debug: Boolean = false
+
private var headers: Map = mutableMapOf()
+
private var connectionPool: ConnectionPool = ConnectionPool(32, 1, TimeUnit.MINUTES)
+
private var connectionPoolManagedByUser: Boolean = false
+ /**
+ * Set the API key used for authentication.
+ *
+ * If not set, the SDK falls back to environment variable `OPEN_SANDBOX_API_KEY`.
+ */
fun apiKey(apiKey: String): Builder {
require(apiKey.isNotBlank()) { "API key cannot be blank" }
this.apiKey = apiKey
return this
}
+ /**
+ * Set the API domain (host[:port]) or a full base URL.
+ *
+ * Examples:
+ * - `pre-agent-sandbox.alibaba-inc.com`
+ * - `localhost:8080`
+ * - `https://pre-agent-sandbox.alibaba-inc.com` (scheme included; [protocol] will be ignored)
+ *
+ * If not set, the SDK falls back to environment variable `OPEN_SANDBOX_DOMAIN`
+ * and then `localhost:8080`.
+ */
fun domain(domain: String): Builder {
require(domain.isNotBlank()) { "Domain cannot be blank" }
this.domain = domain
@@ -100,12 +141,20 @@ class ConnectionConfig private constructor(
/**
* Sets the protocol
* Defaults to "http".
+ *
+ * Note: if [domain] includes a scheme (starts with `http://` or `https://`),
+ * the SDK will use that and ignore this value when building the base URL.
*/
fun protocol(protocol: String): Builder {
this.protocol = protocol.lowercase()
return this
}
+ /**
+ * Sets the request timeout used by the management API HTTP client.
+ *
+ * Must be a positive duration.
+ */
fun requestTimeout(requestTimeout: Duration): Builder {
require(!requestTimeout.isNegative && !requestTimeout.isZero) {
"Request timeout must be positive, got: $requestTimeout"
@@ -114,22 +163,52 @@ class ConnectionConfig private constructor(
return this
}
+ /**
+ * Provide a custom OkHttp [ConnectionPool].
+ *
+ * Ownership semantics:
+ * - When you call this method, the pool is considered user-managed, and the SDK will not
+ * evict it on close.
+ */
fun connectionPool(connectionPool: ConnectionPool): Builder {
this.connectionPool = connectionPool
this.connectionPoolManagedByUser = true
return this
}
+ /**
+ * Enable or disable HTTP request logging (headers).
+ *
+ * This is intended for local debugging. Sensitive headers will be redacted.
+ */
fun debug(enable: Boolean = true): Builder {
this.debug = enable
return this
}
+ /**
+ * Set extra headers that will be sent with every SDK request.
+ *
+ * Note: authentication header is managed by the SDK; you normally should not set
+ * `OPEN-SANDBOX-API-KEY` manually here.
+ */
fun headers(headers: Map): Builder {
this.headers = headers
return this
}
+ /**
+ * Convenience DSL for setting extra headers.
+ *
+ * Example:
+ * ```
+ * ConnectionConfig.builder()
+ * .headers {
+ * put("X-Request-ID", "trace-123")
+ * }
+ * .build()
+ * ```
+ */
fun headers(configure: MutableMap.() -> Unit): Builder {
val map = mutableMapOf()
map.configure()
@@ -137,6 +216,11 @@ class ConnectionConfig private constructor(
return this
}
+ /**
+ * Add a single extra header.
+ *
+ * This is equivalent to mutating [headers] and overwriting the value for the same key.
+ */
fun addHeader(
key: String,
value: String,
@@ -148,6 +232,9 @@ class ConnectionConfig private constructor(
return this
}
+ /**
+ * Build an immutable [ConnectionConfig].
+ */
fun build(): ConnectionConfig {
return ConnectionConfig(
apiKey = apiKey,
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt
index a96b8806..5c083c2b 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt
@@ -263,6 +263,15 @@ class SandboxCreateResponse(
val id: UUID,
)
+/**
+ * Response returned when a sandbox is renewed
+ *
+ * @property expiresAt new expire time after renewal
+ */
+class SandboxRenewResponse(
+ val expiresAt: java.time.OffsetDateTime,
+)
+
/**
* Connection endpoint information for a sandbox.
*
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt
index 19294f31..a3a04cd9 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt
@@ -22,6 +22,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import java.time.Duration
import java.time.OffsetDateTime
import java.util.UUID
@@ -102,11 +103,13 @@ interface Sandboxes {
*
* @param sandboxId Unique identifier of the sandbox
* @param newExpirationTime New expiration timestamp
+ *
+ * @return Sandbox renew response with new expire info
*/
fun renewSandboxExpiration(
sandboxId: UUID,
newExpirationTime: OffsetDateTime,
- )
+ ): SandboxRenewResponse
/**
* Terminates a sandbox and releases all associated resources.
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt
index a0d18a29..1c6b7691 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt
@@ -24,6 +24,7 @@ import com.alibaba.opensandbox.sandbox.api.models.ImageSpec
import com.alibaba.opensandbox.sandbox.api.models.ImageSpecAuth
import com.alibaba.opensandbox.sandbox.api.models.ListSandboxesResponse
import com.alibaba.opensandbox.sandbox.api.models.RenewSandboxExpirationRequest
+import com.alibaba.opensandbox.sandbox.api.models.RenewSandboxExpirationResponse
import com.alibaba.opensandbox.sandbox.api.models.execd.Metrics
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PaginationInfo
@@ -33,6 +34,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageAuth
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import java.time.Duration
import java.time.OffsetDateTime
import com.alibaba.opensandbox.sandbox.api.models.PaginationInfo as ApiPaginationInfo
@@ -178,4 +180,10 @@ internal object SandboxModelConverter {
timestamp = this.timestamp,
)
}
+
+ fun RenewSandboxExpirationResponse.toSandboxRenewResponse(): SandboxRenewResponse {
+ return SandboxRenewResponse(
+ expiresAt = this.expiresAt,
+ )
+ }
}
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt
index 209a123e..6f382590 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt
@@ -24,6 +24,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toApiRenewRequest
@@ -31,6 +32,7 @@ import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.Sandbox
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxCreateResponse
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxEndpoint
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxInfo
+import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxRenewResponse
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException
import org.slf4j.LoggerFactory
import java.time.Duration
@@ -144,15 +146,19 @@ internal class SandboxesAdapter(
override fun renewSandboxExpiration(
sandboxId: UUID,
newExpirationTime: OffsetDateTime,
- ) {
+ ): SandboxRenewResponse {
logger.info("Renew sandbox {} expiration to {}", sandboxId, newExpirationTime)
- try {
- api.sandboxesSandboxIdRenewExpirationPost(
- sandboxId,
- newExpirationTime.toApiRenewRequest(),
- )
+ return try {
+ val response =
+ api.sandboxesSandboxIdRenewExpirationPost(
+ sandboxId,
+ newExpirationTime.toApiRenewRequest(),
+ ).toSandboxRenewResponse()
+
logger.info("Successfully renewed sandbox {} expiration", sandboxId)
+
+ response
} catch (e: Exception) {
logger.error("Failed to renew sandbox {} expiration", sandboxId, e)
throw e.toSandboxException()
diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt
index b1c20ab5..e8c8888d 100644
--- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt
@@ -21,6 +21,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PaginationInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxStatus
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
@@ -32,6 +33,7 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@@ -123,12 +125,13 @@ class SandboxManagerTest {
fun `renewSandbox should call service`() {
val sandboxId = UUID.randomUUID()
val timeout = Duration.ofMinutes(30)
+ val expectedRenew = mockk()
- every { sandboxService.renewSandboxExpiration(sandboxId, any()) } just Runs
+ every { sandboxService.renewSandboxExpiration(sandboxId, any()) } returns expectedRenew
- sandboxManager.renewSandbox(sandboxId, timeout)
+ val actualRenew = sandboxManager.renewSandbox(sandboxId, timeout)
- verify { sandboxService.renewSandboxExpiration(sandboxId, any()) }
+ assertSame(expectedRenew, actualRenew)
}
@Test
diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt
index 71f87aba..b159f036 100644
--- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt
@@ -20,6 +20,7 @@ import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutExce
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.services.Commands
import com.alibaba.opensandbox.sandbox.domain.services.Filesystem
import com.alibaba.opensandbox.sandbox.domain.services.Health
@@ -137,11 +138,12 @@ class SandboxTest {
@Test
fun `renew should delegate to sandboxService`() {
val timeout = Duration.ofMinutes(10)
- every { sandboxService.renewSandboxExpiration(sandboxId, any()) } just Runs
+ val expectedRenew = mockk()
+ every { sandboxService.renewSandboxExpiration(sandboxId, any()) } returns expectedRenew
- sandbox.renew(timeout)
+ val actualRenew = sandbox.renew(timeout)
- verify { sandboxService.renewSandboxExpiration(sandboxId, any()) }
+ assertSame(expectedRenew, actualRenew)
}
@Test
@@ -153,15 +155,6 @@ class SandboxTest {
verify { sandboxService.pauseSandbox(sandboxId) }
}
- @Test
- fun `resume should delegate to sandboxService`() {
- every { sandboxService.resumeSandbox(sandboxId) } just Runs
-
- sandbox.resume()
-
- verify { sandboxService.resumeSandbox(sandboxId) }
- }
-
@Test
fun `kill should delegate to sandboxService`() {
every { sandboxService.killSandbox(sandboxId) } just Runs
diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt
index 3070b654..48cb7908 100644
--- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt
@@ -21,6 +21,9 @@ import com.alibaba.opensandbox.sandbox.config.ConnectionConfig
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
@@ -29,9 +32,6 @@ import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.jsonObject
-import kotlinx.serialization.json.jsonPrimitive
import java.time.Duration
import java.util.UUID
diff --git a/sdks/sandbox/python/README.md b/sdks/sandbox/python/README.md
index 1cf84b76..cb341bb1 100644
--- a/sdks/sandbox/python/README.md
+++ b/sdks/sandbox/python/README.md
@@ -107,7 +107,10 @@ await sandbox.renew(timedelta(minutes=30))
await sandbox.pause()
# Resume execution
-await sandbox.resume()
+sandbox = await Sandbox.resume(
+ sandbox_id=sandbox.id,
+ connection_config=config,
+)
# Get current status
info = await sandbox.get_info()
diff --git a/sdks/sandbox/python/README_zh.md b/sdks/sandbox/python/README_zh.md
index b181f60e..9e02a3da 100644
--- a/sdks/sandbox/python/README_zh.md
+++ b/sdks/sandbox/python/README_zh.md
@@ -107,7 +107,10 @@ await sandbox.renew(timedelta(minutes=30))
await sandbox.pause()
# 恢复执行
-await sandbox.resume()
+sandbox = await Sandbox.resume(
+ sandbox_id=sandbox.id,
+ connection_config=config,
+)
# 获取当前状态
info = await sandbox.get_info()
diff --git a/sdks/sandbox/python/pyproject.toml b/sdks/sandbox/python/pyproject.toml
index 8a76bde1..3cef69fb 100644
--- a/sdks/sandbox/python/pyproject.toml
+++ b/sdks/sandbox/python/pyproject.toml
@@ -101,17 +101,21 @@ ignore = [
"__init__.py" = ["F401"]
[tool.pyright]
-typeCheckingMode = "strict"
+typeCheckingMode = "standard"
pythonVersion = "3.10"
+pythonPlatform = "All"
+
include = ["src"]
-venvPath = "."
-venv = ".venv"
+
exclude = [
"**/node_modules",
"**/__pycache__",
- "**/.*",
"src/opensandbox/api/**",
]
+
+venvPath = "."
+venv = ".venv"
+
reportMissingImports = true
reportMissingTypeStubs = false
diff --git a/sdks/sandbox/python/src/opensandbox/__init__.py b/sdks/sandbox/python/src/opensandbox/__init__.py
index 9c740697..7e786bea 100644
--- a/sdks/sandbox/python/src/opensandbox/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/__init__.py
@@ -96,14 +96,14 @@ async def main():
`opensandbox-code-interpreter` package.
"""
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as _pkg_version
+
from opensandbox.manager import SandboxManager
from opensandbox.sandbox import Sandbox
from opensandbox.sync import SandboxManagerSync, SandboxSync
try:
- from importlib.metadata import PackageNotFoundError
- from importlib.metadata import version as _pkg_version
-
__version__ = _pkg_version("opensandbox")
except PackageNotFoundError: # pragma: no cover
# Fallback for editable/uninstalled source checkouts.
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py
index c13affe0..011af314 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py
@@ -22,6 +22,8 @@
This converter is designed to work with openapi-python-client generated models.
"""
+from typing import Any
+
from opensandbox.api.execd.models.run_command_request import (
RunCommandRequest as ApiRunCommandRequest,
)
@@ -48,9 +50,9 @@ def to_api_run_command_request(command: str, opts: RunCommandOpts) -> ApiRunComm
if opts.working_directory:
cwd = opts.working_directory
- # Convert background, handling None
+ # Convert background. Domain uses bool (default False). When false, omit it from the API request.
background = UNSET
- if opts.background is not None:
+ if opts.background:
background = opts.background
return ApiRunCommandRequest(
@@ -61,7 +63,7 @@ def to_api_run_command_request(command: str, opts: RunCommandOpts) -> ApiRunComm
)
@staticmethod
- def to_api_run_command_json(command: str, opts: RunCommandOpts) -> dict:
+ def to_api_run_command_json(command: str, opts: RunCommandOpts) -> dict[str, Any]:
"""
Convert command + options to a plain JSON-serializable dict for httpx requests.
Centralizes the attrs/pydantic differences behind one callsite.
@@ -69,6 +71,5 @@ def to_api_run_command_json(command: str, opts: RunCommandOpts) -> dict:
api_request = ExecutionConverter.to_api_run_command_request(command, opts)
if hasattr(api_request, "to_dict"):
return api_request.to_dict()
- if hasattr(api_request, "model_dump"):
- return api_request.model_dump(by_alias=True, exclude_none=True)
+ # Fallback (shouldn't normally happen for openapi-python-client models).
return dict(getattr(api_request, "__dict__", {}))
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py
index 160cf5bc..92988bf8 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py
@@ -25,6 +25,7 @@
"""
import logging
+from http import HTTPStatus
from typing import Any, TypeVar
from opensandbox.exceptions import SandboxApiException
@@ -34,6 +35,24 @@
T = TypeVar("T")
+def _status_code_to_int(status_code: Any) -> int:
+ """
+ Normalize status_code from openapi-python-client responses to a plain int.
+
+ openapi-python-client may use http.HTTPStatus; some callers may already provide an int.
+ """
+ if isinstance(status_code, HTTPStatus):
+ return int(status_code)
+ if isinstance(status_code, int):
+ return status_code
+ value = getattr(status_code, "value", None)
+ if isinstance(value, int):
+ return value
+ try:
+ return int(status_code)
+ except Exception:
+ return 0
+
def require_parsed(response_obj: Any, expected_type: type[T], operation_name: str) -> T:
"""
@@ -43,9 +62,7 @@ def require_parsed(response_obj: Any, expected_type: type[T], operation_name: st
- parsed payload must exist
- parsed payload must match the expected type
"""
- status_code = getattr(response_obj, "status_code", 0)
- if hasattr(status_code, "value"):
- status_code = status_code.value
+ status_code = _status_code_to_int(getattr(response_obj, "status_code", 0))
parsed = getattr(response_obj, "parsed", None)
if parsed is None:
@@ -74,9 +91,7 @@ def handle_api_error(response_obj: Any, operation_name: str = "API call") -> Non
Raises:
SandboxApiException: If the response indicates an error
"""
- status_code = response_obj.status_code
- if hasattr(status_code, "value"):
- status_code = status_code.value
+ status_code = _status_code_to_int(getattr(response_obj, "status_code", 0))
logger.debug(f"{operation_name} response: status={status_code}")
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py
index c93877b0..06edd7eb 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py
@@ -22,7 +22,6 @@
This converter is designed to work with openapi-python-client generated models,
which use attrs for model definitions.
"""
-
from datetime import datetime, timedelta, timezone
from uuid import UUID
@@ -31,6 +30,7 @@
Endpoint,
ListSandboxesResponse,
RenewSandboxExpirationRequest,
+ RenewSandboxExpirationResponse,
Sandbox,
)
from opensandbox.api.lifecycle.models import (
@@ -48,6 +48,7 @@
SandboxEndpoint,
SandboxImageSpec,
SandboxInfo,
+ SandboxRenewResponse,
SandboxStatus,
)
@@ -153,6 +154,24 @@ def to_api_renew_request(
expires_at=new_expiration_time,
)
+ @staticmethod
+ def to_sandbox_renew_response(
+ api_response: RenewSandboxExpirationResponse,
+ ) -> SandboxRenewResponse:
+ """
+ Convert API RenewSandboxExpirationResponse to domain SandboxRenewResponse.
+
+ Note: We intentionally keep the public SDK surface using domain models instead of the
+ generated OpenAPI client models.
+ """
+
+ if not isinstance(api_response, RenewSandboxExpirationResponse):
+ raise TypeError(
+ f"Expected RenewSandboxExpirationResponse, got {type(api_response).__name__}"
+ )
+
+ return SandboxRenewResponse(expires_at=api_response.expires_at)
+
@staticmethod
def to_sandbox_create_response(
api_response: CreateSandboxResponse,
@@ -169,7 +188,7 @@ def to_sandbox_create_response(
@staticmethod
def to_sandbox_info(api_sandbox: Sandbox) -> SandboxInfo:
"""Convert API Sandbox to domain SandboxInfo."""
- from opensandbox.api.lifecycle.types import UNSET
+ from opensandbox.api.lifecycle.types import Unset
from opensandbox.models.sandboxes import (
SandboxImageAuth,
SandboxImageSpec,
@@ -177,34 +196,32 @@ def to_sandbox_info(api_sandbox: Sandbox) -> SandboxInfo:
)
domain_image_spec = None
- if hasattr(api_sandbox, "image") and api_sandbox.image is not UNSET:
+ if hasattr(api_sandbox, "image") and not isinstance(api_sandbox.image, Unset):
auth = None
- if (
- hasattr(api_sandbox.image, "auth")
- and api_sandbox.image.auth is not UNSET
- and hasattr(api_sandbox.image.auth, "username")
- and hasattr(api_sandbox.image.auth, "password")
+ if hasattr(api_sandbox.image, "auth") and not isinstance(
+ api_sandbox.image.auth, Unset
):
- username_val = api_sandbox.image.auth.username
- password_val = api_sandbox.image.auth.password
- if not isinstance(username_val, type(UNSET)) and not isinstance(password_val, type(UNSET)):
- auth = SandboxImageAuth(
- username=username_val,
- password=password_val,
- )
+ auth_obj = api_sandbox.image.auth
+ username_val = getattr(auth_obj, "username", None)
+ password_val = getattr(auth_obj, "password", None)
+ if isinstance(username_val, str) and isinstance(password_val, str):
+ auth = SandboxImageAuth(username=username_val, password=password_val)
domain_image_spec = SandboxImageSpec(
image=api_sandbox.image.uri,
auth=auth,
)
metadata: dict[str, str] = {}
- if hasattr(api_sandbox, "metadata") and api_sandbox.metadata is not UNSET:
- if hasattr(api_sandbox.metadata, "additional_properties"):
- props = api_sandbox.metadata.additional_properties
+ if hasattr(api_sandbox, "metadata") and not isinstance(api_sandbox.metadata, Unset):
+ metadata_obj = api_sandbox.metadata
+ if hasattr(metadata_obj, "additional_properties") and not isinstance(
+ getattr(metadata_obj, "additional_properties", None), Unset
+ ):
+ props = metadata_obj.additional_properties
if isinstance(props, dict):
metadata = dict(props)
- elif isinstance(api_sandbox.metadata, dict):
- metadata = api_sandbox.metadata
+ elif isinstance(metadata_obj, dict):
+ metadata = metadata_obj
return SandboxInfo(
id=api_sandbox.id,
@@ -246,7 +263,9 @@ def _convert_sandbox_status(
api_status: ApiSandboxStatus | None,
) -> SandboxStatus:
"""Convert API SandboxStatus to domain SandboxStatus."""
- from opensandbox.api.lifecycle.types import UNSET
+ from datetime import datetime
+
+ from opensandbox.api.lifecycle.types import Unset
from opensandbox.models.sandboxes import SandboxStatus
if api_status is None:
@@ -258,16 +277,24 @@ def _convert_sandbox_status(
)
reason: str | None = None
- if hasattr(api_status, "reason") and api_status.reason is not UNSET:
- reason = api_status.reason if api_status.reason is not None else None
+ if hasattr(api_status, "reason"):
+ reason_val = api_status.reason
+ if isinstance(reason_val, str):
+ reason = reason_val
message: str | None = None
- if hasattr(api_status, "message") and api_status.message is not UNSET:
- message = api_status.message if api_status.message is not None else None
-
- last_transition_at = None
- if hasattr(api_status, "last_transition_at") and api_status.last_transition_at is not UNSET:
- last_transition_at = api_status.last_transition_at if api_status.last_transition_at is not None else None
+ if hasattr(api_status, "message"):
+ message_val = api_status.message
+ if isinstance(message_val, str):
+ message = message_val
+
+ last_transition_at: datetime | None = None
+ if hasattr(api_status, "last_transition_at"):
+ lta_val = api_status.last_transition_at
+ if isinstance(lta_val, datetime):
+ last_transition_at = lta_val
+ elif isinstance(lta_val, Unset) or lta_val is None:
+ last_transition_at = None
return SandboxStatus(
state=api_status.state,
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py
index f92b5010..73c1631a 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py
@@ -24,6 +24,7 @@
import logging
from collections.abc import AsyncIterator
from io import IOBase, TextIOBase
+from typing import TypedDict
import httpx
@@ -49,6 +50,11 @@
logger = logging.getLogger(__name__)
+class _DownloadRequest(TypedDict):
+ url: str
+ params: dict[str, str]
+ headers: dict[str, str]
+
class FilesystemAdapter(Filesystem):
"""
@@ -118,19 +124,21 @@ def _get_execd_url(self, path: str) -> str:
return f"{protocol}://{self.execd_endpoint.endpoint}{path}"
async def read_file(
- self,
- path: str,
- encoding: str = "utf-8",
- range_header: str | None = None,
+ self,
+ path: str,
+ *,
+ encoding: str = "utf-8",
+ range_header: str | None = None,
) -> str:
"""Read file content as string via HTTP API."""
- content = await self.read_bytes(path, range_header)
+ content = await self.read_bytes(path, range_header=range_header)
return content.decode(encoding)
async def read_bytes(
- self,
- path: str,
- range_header: str | None = None,
+ self,
+ path: str,
+ *,
+ range_header: str | None = None,
) -> bytes:
"""Read file content as bytes with support for range requests.
@@ -152,7 +160,7 @@ async def read_bytes(
response = await client.get(
request_data["url"],
params=request_data["params"],
- headers=request_data.get("headers"),
+ headers=request_data["headers"],
)
response.raise_for_status()
return response.content
@@ -175,7 +183,7 @@ async def read_bytes_stream(
url = request_data["url"]
params = request_data["params"]
- headers = request_data.get("headers", {})
+ headers = request_data["headers"]
request = client.build_request(
"GET",
@@ -269,7 +277,8 @@ async def write_files(self, entries: list[WriteEntry]) -> None:
async def write_file(
self,
path: str,
- content: str | bytes | IOBase,
+ data: str | bytes | IOBase,
+ *,
encoding: str = "utf-8",
mode: int = 755,
owner: str | None = None,
@@ -278,7 +287,7 @@ async def write_file(
"""Write single file (convenience method)."""
entry = WriteEntry(
path=path,
- data=content,
+ data=data,
mode=mode,
owner=owner,
group=group,
@@ -407,6 +416,7 @@ async def search(self, entry: SearchEntry) -> list[EntryInfo]:
"""Search files using auto-generated API."""
try:
from opensandbox.api.execd.api.filesystem import search_files
+ from opensandbox.api.execd.models import FileInfo
client = await self._get_client()
response_obj = await search_files.asyncio_detailed(
@@ -417,10 +427,15 @@ async def search(self, entry: SearchEntry) -> list[EntryInfo]:
handle_api_error(response_obj, "Search files")
- if not response_obj.parsed:
+ parsed = response_obj.parsed
+ if not parsed:
return []
- return FilesystemModelConverter.to_entry_info_list(response_obj.parsed)
+ if isinstance(parsed, list) and all(isinstance(x, FileInfo) for x in parsed):
+ return FilesystemModelConverter.to_entry_info_list(parsed)
+ raise SandboxApiException(
+ message="Search files failed: unexpected response type",
+ )
except Exception as e:
logger.error("Failed to search files", exc_info=e)
@@ -450,7 +465,7 @@ async def get_file_info(self, paths: list[str]) -> dict[str, EntryInfo]:
def _build_download_request(
self, path: str, range_header: str | None = None
- ) -> dict[str, str | dict[str, str]]:
+ ) -> _DownloadRequest:
"""Build HTTP request for file download operations.
Args:
@@ -462,7 +477,7 @@ def _build_download_request(
"""
url = self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)
params = {"path": path}
- headers = {}
+ headers: dict[str, str] = {}
if range_header:
headers["Range"] = range_header
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/sandboxes_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/sandboxes_adapter.py
index 52205084..449b3d51 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/sandboxes_adapter.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/sandboxes_adapter.py
@@ -46,6 +46,7 @@
SandboxFilter,
SandboxImageSpec,
SandboxInfo,
+ SandboxRenewResponse,
)
from opensandbox.services.sandbox import Sandboxes
@@ -294,7 +295,7 @@ async def resume_sandbox(self, sandbox_id: UUID) -> None:
async def renew_sandbox_expiration(
self, sandbox_id: UUID, new_expiration_time: datetime
- ) -> None:
+ ) -> SandboxRenewResponse:
"""Extend the expiration time of a sandbox."""
logger.info(f"Renew sandbox {sandbox_id} expiration to {new_expiration_time}")
@@ -302,6 +303,9 @@ async def renew_sandbox_expiration(
from opensandbox.api.lifecycle.api.sandboxes import (
post_sandboxes_sandbox_id_renew_expiration,
)
+ from opensandbox.api.lifecycle.models.renew_sandbox_expiration_response import (
+ RenewSandboxExpirationResponse,
+ )
renew_request = SandboxModelConverter.to_api_renew_request(
new_expiration_time
@@ -318,7 +322,18 @@ async def renew_sandbox_expiration(
handle_api_error(response_obj, f"Renew sandbox {sandbox_id} expiration")
- logger.info(f"Successfully renewed sandbox {sandbox_id} expiration")
+ parsed = require_parsed(
+ response_obj,
+ RenewSandboxExpirationResponse,
+ f"Renew sandbox {sandbox_id} expiration",
+ )
+ renew_response = SandboxModelConverter.to_sandbox_renew_response(parsed)
+ logger.info(
+ "Successfully renewed sandbox %s expiration to %s",
+ sandbox_id,
+ renew_response.expires_at,
+ )
+ return renew_response
except Exception as e:
logger.error(f"Failed to renew sandbox {sandbox_id} expiration", exc_info=e)
diff --git a/sdks/sandbox/python/src/opensandbox/manager.py b/sdks/sandbox/python/src/opensandbox/manager.py
index 3798d5f3..a6e17735 100644
--- a/sdks/sandbox/python/src/opensandbox/manager.py
+++ b/sdks/sandbox/python/src/opensandbox/manager.py
@@ -30,6 +30,7 @@
PagedSandboxInfos,
SandboxFilter,
SandboxInfo,
+ SandboxRenewResponse,
)
from opensandbox.services.sandbox import Sandboxes
@@ -162,7 +163,7 @@ async def kill_sandbox(self, sandbox_id: UUID) -> None:
await self._sandbox_service.kill_sandbox(sandbox_id)
logger.info(f"Successfully terminated sandbox: {sandbox_id}")
- async def renew_sandbox(self, sandbox_id: UUID, timeout: timedelta) -> None:
+ async def renew_sandbox(self, sandbox_id: UUID, timeout: timedelta) -> SandboxRenewResponse:
"""
Renew expiration time for a single sandbox.
@@ -178,7 +179,9 @@ async def renew_sandbox(self, sandbox_id: UUID, timeout: timedelta) -> None:
# Use timezone-aware UTC datetime to avoid cross-timezone ambiguity.
new_expiration = datetime.now(timezone.utc) + timeout
logger.info(f"Renew expiration for sandbox {sandbox_id} to {new_expiration}")
- await self._sandbox_service.renew_sandbox_expiration(sandbox_id, new_expiration)
+ return await self._sandbox_service.renew_sandbox_expiration(
+ sandbox_id, new_expiration
+ )
async def pause_sandbox(self, sandbox_id: UUID) -> None:
"""
diff --git a/sdks/sandbox/python/src/opensandbox/models/__init__.py b/sdks/sandbox/python/src/opensandbox/models/__init__.py
index 102c5ec8..dc702697 100644
--- a/sdks/sandbox/python/src/opensandbox/models/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/models/__init__.py
@@ -46,6 +46,7 @@
SandboxImageSpec,
SandboxInfo,
SandboxMetrics,
+ SandboxState,
SandboxStatus,
)
@@ -68,6 +69,7 @@
# Sandbox models
"SandboxInfo",
"SandboxStatus",
+ "SandboxState",
"SandboxCreateResponse",
"SandboxEndpoint",
"SandboxImageSpec",
diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py
index 24b745b8..928ee0ce 100644
--- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py
+++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py
@@ -147,6 +147,19 @@ class SandboxCreateResponse(BaseModel):
id: UUID = Field(description="Unique identifier of the newly created sandbox")
+class SandboxRenewResponse(BaseModel):
+ """
+ Response returned when renewing a sandbox expiration time.
+ """
+
+ expires_at: datetime = Field(
+ description="The new absolute expiration time in UTC (RFC 3339 format).",
+ alias="expires_at",
+ )
+
+ model_config = ConfigDict(populate_by_name=True)
+
+
class SandboxEndpoint(BaseModel):
"""
Connection endpoint information for a sandbox.
@@ -242,3 +255,48 @@ class SandboxMetrics(BaseModel):
)
model_config = ConfigDict(populate_by_name=True)
+
+
+class SandboxState:
+ """High-level lifecycle state of the sandbox.
+
+ This class provides constant string values for sandbox states.
+ Note that the sandbox service may introduce new states in future
+ versions; clients should handle unknown string values gracefully.
+
+ Common States:
+ PENDING (str): Sandbox is being provisioned.
+ RUNNING (str): Sandbox is running and ready to accept requests.
+ PAUSING (str): Sandbox is in the process of pausing.
+ PAUSED (str): Sandbox has been paused while retaining its state.
+ STOPPING (str): Sandbox is being terminated.
+ TERMINATED (str): Sandbox has been successfully terminated.
+ FAILED (str): Sandbox encountered a critical error.
+ UNKNOWN (str): State is unknown or unsupported by the current version.
+
+ State Transitions:
+ - Pending -> Running: After creation completes.
+ - Running -> Pausing: When pause is requested.
+ - Pausing -> Paused: After pause operation completes.
+ - Paused -> Running: When resume is requested.
+ - Running/Paused -> Stopping: When kill is requested or TTL expires.
+ - Stopping -> Terminated: After kill/timeout operation completes.
+ - Pending/Running/Paused -> Failed: On critical error.
+ """
+
+ PENDING = "Pending"
+ RUNNING = "Running"
+ PAUSING = "Pausing"
+ PAUSED = "Paused"
+ STOPPING = "Stopping"
+ TERMINATED = "Terminated"
+ FAILED = "Failed"
+ UNKNOWN = "Unknown"
+
+ @classmethod
+ def values(cls) -> set[str]:
+ """Returns a set of all known state values."""
+ return {
+ v for k, v in cls.__dict__.items()
+ if k.isupper() and not k.startswith("_")
+ }
diff --git a/sdks/sandbox/python/src/opensandbox/sandbox.py b/sdks/sandbox/python/src/opensandbox/sandbox.py
index d7eb0cbc..8839e431 100644
--- a/sdks/sandbox/python/src/opensandbox/sandbox.py
+++ b/sdks/sandbox/python/src/opensandbox/sandbox.py
@@ -33,13 +33,13 @@
SandboxException,
SandboxInternalException,
SandboxReadyTimeoutException,
- SandboxUnhealthyException,
)
from opensandbox.models.sandboxes import (
SandboxEndpoint,
SandboxImageSpec,
SandboxInfo,
SandboxMetrics,
+ SandboxRenewResponse,
)
from opensandbox.services import (
Commands,
@@ -196,7 +196,7 @@ async def get_metrics(self) -> SandboxMetrics:
"""
return await self._metrics_service.get_metrics(self.id)
- async def renew(self, timeout: timedelta) -> None:
+ async def renew(self, timeout: timedelta) -> SandboxRenewResponse:
"""
Renew the sandbox expiration time to delay automatic termination.
@@ -205,6 +205,9 @@ async def renew(self, timeout: timedelta) -> None:
Args:
timeout: Duration to add to the current time to set the new expiration
+ Returns:
+ Renew response including the new expiration time.
+
Raises:
SandboxException: if the operation fails
"""
@@ -213,7 +216,7 @@ async def renew(self, timeout: timedelta) -> None:
logger.info(
f"Renewing sandbox {self.id} timeout, estimated expiration: {new_expiration}"
)
- await self._sandbox_service.renew_sandbox_expiration(self.id, new_expiration)
+ return await self._sandbox_service.renew_sandbox_expiration(self.id, new_expiration)
async def pause(self) -> None:
"""
@@ -228,18 +231,6 @@ async def pause(self) -> None:
logger.info(f"Pausing sandbox: {self.id}")
await self._sandbox_service.pause_sandbox(self.id)
- async def resume(self) -> None:
- """
- Resume a previously paused sandbox.
-
- The sandbox will transition from PAUSED to RUNNING state and all
- suspended processes will be resumed.
-
- Raises:
- SandboxException: if resume operation fails
- """
- logger.info(f"Resuming sandbox: {self.id}")
- await self._sandbox_service.resume_sandbox(self.id)
async def kill(self) -> None:
"""
@@ -371,6 +362,7 @@ async def create(
connection_config: ConnectionConfig | None = None,
health_check: Callable[["Sandbox"], Awaitable[bool]] | None = None,
health_check_polling_interval: timedelta = timedelta(milliseconds=200),
+ skip_health_check: bool = False,
) -> "Sandbox":
"""
Create a new sandbox instance with the specified configuration.
@@ -388,6 +380,7 @@ async def create(
connection_config: Connection configuration
health_check: Custom async health check function
health_check_polling_interval: Time between health check attempts
+ skip_health_check: If True, do NOT wait for sandbox readiness/health; returned instance may not be ready yet.
Returns:
Fully configured and ready Sandbox instance
@@ -408,10 +401,9 @@ async def create(
logger.info(
f"Creating sandbox with image: {image.image} (timeout: {timeout.total_seconds()}s)"
)
-
factory = AdapterFactory(config)
- sandbox_id = None
- sandbox_service = None
+ sandbox_id: UUID | None = None
+ sandbox_service: Sandboxes | None = None
try:
sandbox_service = factory.create_sandbox_service()
@@ -441,26 +433,32 @@ async def create(
custom_health_check=health_check,
)
- await sandbox.check_ready(ready_timeout, health_check_polling_interval)
+ if not skip_health_check:
+ await sandbox.check_ready(ready_timeout, health_check_polling_interval)
+ logger.info("Sandbox %s is ready", sandbox.id)
+ else:
+ logger.info(
+ "Sandbox %s created (skip_health_check=true, sandbox may not be ready yet)",
+ sandbox.id,
+ )
- logger.info(f"Sandbox {sandbox.id} is ready")
return sandbox
-
except Exception as e:
- # Cleanup on failure
if sandbox_id and sandbox_service:
try:
logger.warning(
- f"Creation failed, cleaning up sandbox: {sandbox_id}"
+ "Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: %s",
+ sandbox_id,
)
await sandbox_service.kill_sandbox(sandbox_id)
except Exception as cleanup_ex:
logger.error(
- f"Failed to cleanup sandbox {sandbox_id}", exc_info=cleanup_ex
+ "Failed to clean up sandbox %s after creation failure",
+ sandbox_id,
+ exc_info=cleanup_ex,
)
await config.close_transport_if_owned()
-
if isinstance(e, SandboxException):
raise
logger.error("Unexpected exception during sandbox creation", exc_info=e)
@@ -474,6 +472,9 @@ async def connect(
sandbox_id: str | UUID,
connection_config: ConnectionConfig | None = None,
health_check: Callable[["Sandbox"], Awaitable[bool]] | None = None,
+ connect_timeout: timedelta = timedelta(seconds=30),
+ health_check_polling_interval: timedelta = timedelta(milliseconds=200),
+ skip_health_check: bool = False,
) -> "Sandbox":
"""
Connect to an existing sandbox instance by ID.
@@ -482,6 +483,9 @@ async def connect(
sandbox_id: ID of the existing sandbox
connection_config: Connection configuration
health_check: Custom async health check function
+ connect_timeout: Max time to wait for sandbox readiness/health after connecting.
+ health_check_polling_interval: Polling interval used while waiting for readiness/health.
+ skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.
Returns:
Connected Sandbox instance
@@ -499,7 +503,6 @@ async def connect(
config = connection_config or ConnectionConfig()
logger.info(f"Connecting to sandbox: {sandbox_id}")
-
factory = AdapterFactory(config)
try:
@@ -519,22 +522,94 @@ async def connect(
custom_health_check=health_check,
)
- if not await sandbox.is_healthy():
- raise SandboxUnhealthyException(
- f"Failed to connect: sandbox {sandbox_id} is unhealthy"
+ if not skip_health_check:
+ await sandbox.check_ready(connect_timeout, health_check_polling_interval)
+ else:
+ logger.info(
+ "Connected to sandbox %s (skip_health_check=true, sandbox may not be ready yet)",
+ sandbox_id,
)
- logger.info(f"Connected to sandbox {sandbox_id}")
+ logger.info("Connected to sandbox %s", sandbox_id)
return sandbox
-
except Exception as e:
await config.close_transport_if_owned()
-
if isinstance(e, SandboxException):
raise
logger.error("Unexpected exception during sandbox connection", exc_info=e)
raise SandboxInternalException(f"Failed to connect to sandbox: {e}") from e
+ @classmethod
+ async def resume(
+ cls,
+ sandbox_id: str | UUID,
+ connection_config: ConnectionConfig | None = None,
+ health_check: Callable[["Sandbox"], Awaitable[bool]] | None = None,
+ resume_timeout: timedelta = timedelta(seconds=30),
+ health_check_polling_interval: timedelta = timedelta(milliseconds=200),
+ skip_health_check: bool = False,
+ ) -> "Sandbox":
+ """
+ Resume a paused sandbox by ID and return a new, usable Sandbox instance.
+
+ This method performs the server-side resume operation, then re-resolves the execd endpoint
+ (which may change across pause/resume on some backends), rebuilds service adapters, and
+ optionally waits for readiness/health.
+
+ Args:
+ sandbox_id: ID of the paused sandbox to resume.
+ connection_config: Connection configuration (shared transport, headers, timeouts).
+ health_check: Optional custom async health check function (falls back to ping).
+ resume_timeout: Max time to wait for sandbox readiness/health after resuming.
+ health_check_polling_interval: Polling interval used while waiting for readiness/health.
+ skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.
+ """
+ if not sandbox_id:
+ raise InvalidArgumentException("Sandbox ID must be specified")
+
+ if isinstance(sandbox_id, str):
+ sandbox_id = UUID(sandbox_id)
+
+ config = connection_config or ConnectionConfig()
+
+ logger.info("Resuming sandbox: %s", sandbox_id)
+ factory = AdapterFactory(config)
+
+ try:
+ sandbox_service = factory.create_sandbox_service()
+ await sandbox_service.resume_sandbox(sandbox_id)
+
+ execd_endpoint = await sandbox_service.get_sandbox_endpoint(
+ sandbox_id, DEFAULT_EXECD_PORT
+ )
+
+ sandbox = cls(
+ sandbox_id=sandbox_id,
+ sandbox_service=sandbox_service,
+ filesystem_service=factory.create_filesystem_service(execd_endpoint),
+ command_service=factory.create_command_service(execd_endpoint),
+ health_service=factory.create_health_service(execd_endpoint),
+ metrics_service=factory.create_metrics_service(execd_endpoint),
+ connection_config=config,
+ custom_health_check=health_check,
+ )
+
+ if not skip_health_check:
+ await sandbox.check_ready(resume_timeout, health_check_polling_interval)
+ else:
+ logger.info(
+ "Resumed sandbox %s (skip_health_check=true, sandbox may not be ready yet)",
+ sandbox_id,
+ )
+
+ return sandbox
+ except Exception as e:
+ await config.close_transport_if_owned()
+ if isinstance(e, SandboxException):
+ raise
+ logger.error("Unexpected exception during sandbox resume", exc_info=e)
+ raise SandboxInternalException(f"Failed to resume sandbox: {e}") from e
+
async def __aenter__(self) -> "Sandbox":
"""Async context manager entry."""
return self
diff --git a/sdks/sandbox/python/src/opensandbox/services/sandbox.py b/sdks/sandbox/python/src/opensandbox/services/sandbox.py
index 77131f56..9e58f1e4 100644
--- a/sdks/sandbox/python/src/opensandbox/services/sandbox.py
+++ b/sdks/sandbox/python/src/opensandbox/services/sandbox.py
@@ -30,6 +30,7 @@
SandboxFilter,
SandboxImageSpec,
SandboxInfo,
+ SandboxRenewResponse,
)
@@ -146,7 +147,7 @@ async def resume_sandbox(self, sandbox_id: UUID) -> None:
async def renew_sandbox_expiration(
self, sandbox_id: UUID, new_expiration_time: datetime
- ) -> None:
+ ) -> SandboxRenewResponse:
"""
Renew the expiration time of a sandbox.
@@ -154,6 +155,9 @@ async def renew_sandbox_expiration(
sandbox_id: Unique identifier of the sandbox
new_expiration_time: New expiration timestamp
+ Returns:
+ Renew response including the new expiration time.
+
Raises:
SandboxException: if the operation fails
"""
diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py
index 72ebd5e4..306988db 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py
@@ -21,6 +21,7 @@
import logging
from collections.abc import Iterator
from io import IOBase, TextIOBase
+from typing import TypedDict
import httpx
@@ -46,6 +47,11 @@
logger = logging.getLogger(__name__)
+class _DownloadRequest(TypedDict):
+ url: str
+ params: dict[str, str]
+ headers: dict[str, str]
+
class FilesystemAdapterSync(FilesystemSync):
FILESYSTEM_UPLOAD_PATH = "/files/upload"
@@ -76,7 +82,7 @@ def _get_execd_base_url(self) -> str:
def _get_execd_url(self, path: str) -> str:
return f"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}{path}"
- def _build_download_request(self, path: str, range_header: str | None = None) -> dict:
+ def _build_download_request(self, path: str, range_header: str | None = None) -> _DownloadRequest:
url = self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)
params = {"path": path}
headers: dict[str, str] = {}
@@ -101,7 +107,7 @@ def read_bytes(self, path: str, *, range_header: str | None = None) -> bytes:
response = self._httpx_client.get(
request_data["url"],
params=request_data["params"],
- headers=request_data.get("headers"),
+ headers=request_data["headers"],
)
response.raise_for_status()
return response.content
@@ -116,7 +122,7 @@ def read_bytes_stream(
request_data = self._build_download_request(path, range_header)
url = request_data["url"]
params = request_data["params"]
- headers = request_data.get("headers", {})
+ headers = request_data["headers"]
request = self._httpx_client.build_request("GET", url, params=params, headers=headers)
response = self._httpx_client.send(request, stream=True)
@@ -190,8 +196,8 @@ def write_files(self, entries: list[WriteEntry]) -> None:
def write_file(
self,
path: str,
- *,
data: str | bytes | IOBase,
+ *,
encoding: str = "utf-8",
mode: int = 755,
owner: str | None = None,
@@ -273,6 +279,7 @@ def replace_contents(self, entries: list[ContentReplaceEntry]) -> None:
def search(self, entry: SearchEntry) -> list[EntryInfo]:
try:
from opensandbox.api.execd.api.filesystem import search_files
+ from opensandbox.api.execd.models import FileInfo
response_obj = search_files.sync_detailed(
client=self._client,
@@ -280,9 +287,12 @@ def search(self, entry: SearchEntry) -> list[EntryInfo]:
pattern=entry.pattern,
)
handle_api_error(response_obj, "Search files")
- if not response_obj.parsed:
+ parsed = response_obj.parsed
+ if not parsed:
return []
- return FilesystemModelConverter.to_entry_info_list(response_obj.parsed)
+ if isinstance(parsed, list) and all(isinstance(x, FileInfo) for x in parsed):
+ return FilesystemModelConverter.to_entry_info_list(parsed)
+ raise SandboxApiException(message="Search files failed: unexpected response type")
except Exception as e:
logger.error("Failed to search files", exc_info=e)
raise ExceptionConverter.to_sandbox_exception(e) from e
diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/sandboxes_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/sandboxes_adapter.py
index bf555059..67690dd6 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/adapters/sandboxes_adapter.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/sandboxes_adapter.py
@@ -42,6 +42,7 @@
SandboxFilter,
SandboxImageSpec,
SandboxInfo,
+ SandboxRenewResponse,
)
from opensandbox.sync.services.sandbox import SandboxesSync
@@ -217,11 +218,16 @@ def resume_sandbox(self, sandbox_id: UUID) -> None:
logger.error("Failed to resume sandbox: %s", sandbox_id, exc_info=e)
raise ExceptionConverter.to_sandbox_exception(e) from e
- def renew_sandbox_expiration(self, sandbox_id: UUID, new_expiration_time: datetime) -> None:
+ def renew_sandbox_expiration(
+ self, sandbox_id: UUID, new_expiration_time: datetime
+ ) -> SandboxRenewResponse:
try:
from opensandbox.api.lifecycle.api.sandboxes import (
post_sandboxes_sandbox_id_renew_expiration,
)
+ from opensandbox.api.lifecycle.models.renew_sandbox_expiration_response import (
+ RenewSandboxExpirationResponse,
+ )
renew_request = SandboxModelConverter.to_api_renew_request(new_expiration_time)
response_obj = post_sandboxes_sandbox_id_renew_expiration.sync_detailed(
@@ -230,6 +236,12 @@ def renew_sandbox_expiration(self, sandbox_id: UUID, new_expiration_time: dateti
body=renew_request,
)
handle_api_error(response_obj, f"Renew sandbox {sandbox_id} expiration")
+ parsed = require_parsed(
+ response_obj,
+ RenewSandboxExpirationResponse,
+ f"Renew sandbox {sandbox_id} expiration",
+ )
+ return SandboxModelConverter.to_sandbox_renew_response(parsed)
except Exception as e:
logger.error("Failed to renew sandbox %s expiration", sandbox_id, exc_info=e)
raise ExceptionConverter.to_sandbox_exception(e) from e
diff --git a/sdks/sandbox/python/src/opensandbox/sync/manager.py b/sdks/sandbox/python/src/opensandbox/sync/manager.py
index c861c0d8..12cbcddf 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/manager.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/manager.py
@@ -27,6 +27,7 @@
PagedSandboxInfos,
SandboxFilter,
SandboxInfo,
+ SandboxRenewResponse,
)
from opensandbox.sync.adapters.factory import AdapterFactorySync
from opensandbox.sync.services.sandbox import SandboxesSync
@@ -139,7 +140,7 @@ def kill_sandbox(self, sandbox_id: UUID) -> None:
self._sandbox_service.kill_sandbox(sandbox_id)
logger.info("Successfully terminated sandbox: %s", sandbox_id)
- def renew_sandbox(self, sandbox_id: UUID, timeout: timedelta) -> None:
+ def renew_sandbox(self, sandbox_id: UUID, timeout: timedelta) -> SandboxRenewResponse:
"""
Renew expiration time for a single sandbox.
@@ -155,7 +156,7 @@ def renew_sandbox(self, sandbox_id: UUID, timeout: timedelta) -> None:
# Use timezone-aware UTC datetime to avoid cross-timezone ambiguity.
new_expiration = datetime.now(timezone.utc) + timeout
logger.info("Renew expiration for sandbox %s to %s", sandbox_id, new_expiration)
- self._sandbox_service.renew_sandbox_expiration(sandbox_id, new_expiration)
+ return self._sandbox_service.renew_sandbox_expiration(sandbox_id, new_expiration)
def pause_sandbox(self, sandbox_id: UUID) -> None:
"""
diff --git a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py
index 78810e77..34f1d819 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py
@@ -31,13 +31,13 @@
SandboxException,
SandboxInternalException,
SandboxReadyTimeoutException,
- SandboxUnhealthyException,
)
from opensandbox.models.sandboxes import (
SandboxEndpoint,
SandboxImageSpec,
SandboxInfo,
SandboxMetrics,
+ SandboxRenewResponse,
)
from opensandbox.sync.adapters.factory import AdapterFactorySync
from opensandbox.sync.services import (
@@ -202,7 +202,7 @@ def get_metrics(self) -> SandboxMetrics:
"""
return self._metrics_service.get_metrics(self.id)
- def renew(self, timeout: timedelta) -> None:
+ def renew(self, timeout: timedelta) -> SandboxRenewResponse:
"""
Renew the sandbox expiration time to delay automatic termination.
@@ -211,6 +211,9 @@ def renew(self, timeout: timedelta) -> None:
Args:
timeout: Duration to add to the current time to set the new expiration
+ Returns:
+ Renew response including the new expiration time.
+
Raises:
SandboxException: if the operation fails
"""
@@ -221,7 +224,7 @@ def renew(self, timeout: timedelta) -> None:
self.id,
new_expiration,
)
- self._sandbox_service.renew_sandbox_expiration(self.id, new_expiration)
+ return self._sandbox_service.renew_sandbox_expiration(self.id, new_expiration)
def pause(self) -> None:
"""
@@ -236,18 +239,6 @@ def pause(self) -> None:
logger.info("Pausing sandbox: %s", self.id)
self._sandbox_service.pause_sandbox(self.id)
- def resume(self) -> None:
- """
- Resume a previously paused sandbox.
-
- The sandbox will transition from PAUSED to RUNNING state and all
- suspended processes will be resumed.
-
- Raises:
- SandboxException: if resume operation fails
- """
- logger.info("Resuming sandbox: %s", self.id)
- self._sandbox_service.resume_sandbox(self.id)
def kill(self) -> None:
"""
@@ -356,6 +347,7 @@ def create(
connection_config: ConnectionConfigSync | None = None,
health_check: Callable[["SandboxSync"], bool] | None = None,
health_check_polling_interval: timedelta = timedelta(milliseconds=200),
+ skip_health_check: bool = False,
) -> "SandboxSync":
"""
Create a new sandbox instance with the specified configuration (blocking).
@@ -373,6 +365,7 @@ def create(
connection_config: Connection configuration
health_check: Custom sync health check function
health_check_polling_interval: Time between health check attempts
+ skip_health_check: If True, do NOT wait for sandbox readiness/health; returned instance may not be ready yet.
Returns:
Fully configured and ready SandboxSync instance
@@ -395,7 +388,6 @@ def create(
image.image,
timeout.total_seconds(),
)
-
factory = AdapterFactorySync(config)
sandbox_id: UUID | None = None
sandbox_service: SandboxesSync | None = None
@@ -419,13 +411,23 @@ def create(
custom_health_check=health_check,
)
- sandbox.check_ready(ready_timeout, health_check_polling_interval)
- logger.info("Sandbox %s is ready", sandbox.id)
+ if not skip_health_check:
+ sandbox.check_ready(ready_timeout, health_check_polling_interval)
+ logger.info("Sandbox %s is ready", sandbox.id)
+ else:
+ logger.info(
+ "Sandbox %s created (skip_health_check=true, sandbox may not be ready yet)",
+ sandbox.id,
+ )
+
return sandbox
except Exception as e:
if sandbox_id and sandbox_service:
try:
- logger.warning("Creation failed, cleaning up sandbox: %s", sandbox_id)
+ logger.warning(
+ "Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: %s",
+ sandbox_id,
+ )
sandbox_service.kill_sandbox(sandbox_id)
except Exception:
pass
@@ -440,6 +442,9 @@ def connect(
sandbox_id: str | UUID,
connection_config: ConnectionConfigSync | None = None,
health_check: Callable[["SandboxSync"], bool] | None = None,
+ connect_timeout: timedelta = timedelta(seconds=30),
+ health_check_polling_interval: timedelta = timedelta(milliseconds=200),
+ skip_health_check: bool = False,
) -> "SandboxSync":
"""
Connect to an existing sandbox instance by ID (blocking).
@@ -448,6 +453,9 @@ def connect(
sandbox_id: ID of the existing sandbox
connection_config: Connection configuration
health_check: Custom sync health check function
+ connect_timeout: Max time to wait for sandbox readiness/health after connecting.
+ health_check_polling_interval: Polling interval used while waiting for readiness/health.
+ skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.
Returns:
Connected SandboxSync instance
@@ -479,8 +487,15 @@ def connect(
connection_config=config,
custom_health_check=health_check,
)
- if not sandbox.is_healthy():
- raise SandboxUnhealthyException(f"Failed to connect: sandbox {sandbox_id} is unhealthy")
+
+ if not skip_health_check:
+ sandbox.check_ready(connect_timeout, health_check_polling_interval)
+ else:
+ logger.info(
+ "Connected to sandbox %s (skip_health_check=true, sandbox may not be ready yet)",
+ sandbox_id,
+ )
+
logger.info("Connected to sandbox %s", sandbox_id)
return sandbox
except Exception as e:
@@ -489,6 +504,75 @@ def connect(
raise
raise SandboxInternalException(f"Failed to connect to sandbox: {e}") from e
+ @classmethod
+ def resume(
+ cls,
+ sandbox_id: str | UUID,
+ connection_config: ConnectionConfigSync | None = None,
+ health_check: Callable[["SandboxSync"], bool] | None = None,
+ resume_timeout: timedelta = timedelta(seconds=30),
+ health_check_polling_interval: timedelta = timedelta(milliseconds=200),
+ skip_health_check: bool = False,
+ ) -> "SandboxSync":
+ """
+ Resume a paused sandbox by ID and return a new, usable SandboxSync instance.
+
+ This method performs the server-side resume operation, then re-resolves the execd endpoint
+ (which may change across pause/resume on some backends), rebuilds service adapters, and
+ optionally waits for readiness/health.
+
+ Args:
+ sandbox_id: ID of the paused sandbox to resume.
+ connection_config: Connection configuration (shared transport, headers, timeouts).
+ health_check: Optional custom sync health check function (falls back to ping).
+ resume_timeout: Max time to wait for sandbox readiness/health after resuming.
+ health_check_polling_interval: Polling interval used while waiting for readiness/health.
+ skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.
+ """
+ if not sandbox_id:
+ raise InvalidArgumentException("Sandbox ID must be specified")
+
+ if isinstance(sandbox_id, str):
+ sandbox_id = UUID(sandbox_id)
+
+ config = connection_config or ConnectionConfigSync()
+
+ logger.info("Resuming sandbox: %s", sandbox_id)
+ factory = AdapterFactorySync(config)
+
+ try:
+ sandbox_service = factory.create_sandbox_service()
+ sandbox_service.resume_sandbox(sandbox_id)
+
+ execd_endpoint = sandbox_service.get_sandbox_endpoint(sandbox_id, DEFAULT_EXECD_PORT)
+
+ sandbox = cls(
+ sandbox_id=sandbox_id,
+ sandbox_service=sandbox_service,
+ filesystem_service=factory.create_filesystem_service(execd_endpoint),
+ command_service=factory.create_command_service(execd_endpoint),
+ health_service=factory.create_health_service(execd_endpoint),
+ metrics_service=factory.create_metrics_service(execd_endpoint),
+ connection_config=config,
+ custom_health_check=health_check,
+ )
+
+ if not skip_health_check:
+ sandbox.check_ready(resume_timeout, health_check_polling_interval)
+ else:
+ logger.info(
+ "Resumed sandbox %s (skip_health_check=true, sandbox may not be ready yet)",
+ sandbox_id,
+ )
+
+ return sandbox
+ except Exception as e:
+ config.close_transport_if_owned()
+ if isinstance(e, SandboxException):
+ raise
+ raise SandboxInternalException(f"Failed to resume sandbox: {e}") from e
+
+
def __enter__(self) -> "SandboxSync":
"""Sync context manager entry."""
return self
diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/sandbox.py b/sdks/sandbox/python/src/opensandbox/sync/services/sandbox.py
index b0e98f2f..89f76069 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/services/sandbox.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/services/sandbox.py
@@ -31,6 +31,7 @@
SandboxFilter,
SandboxImageSpec,
SandboxInfo,
+ SandboxRenewResponse,
)
@@ -143,7 +144,9 @@ def resume_sandbox(self, sandbox_id: UUID) -> None:
"""
...
- def renew_sandbox_expiration(self, sandbox_id: UUID, new_expiration_time: datetime) -> None:
+ def renew_sandbox_expiration(
+ self, sandbox_id: UUID, new_expiration_time: datetime
+ ) -> SandboxRenewResponse:
"""
Renew the expiration time of a sandbox.
@@ -151,6 +154,9 @@ def renew_sandbox_expiration(self, sandbox_id: UUID, new_expiration_time: dateti
sandbox_id: Unique identifier of the sandbox.
new_expiration_time: New expiration timestamp (timezone-aware recommended).
+ Returns:
+ Renew response including the new expiration time.
+
Raises:
SandboxException: If the operation fails.
"""
diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml
index 10b96fac..f3c70063 100644
--- a/specs/execd-api.yaml
+++ b/specs/execd-api.yaml
@@ -72,7 +72,7 @@ paths:
parameters:
- name: language
in: query
- required: false
+ required: true
description: Filter contexts by execution runtime (python, bash, java, etc.)
schema:
type: string
@@ -124,6 +124,33 @@ paths:
$ref: "#/components/responses/InternalServerError"
/code/contexts/{context_id}:
+ get:
+ summary: Get a code execution context by id
+ description: |
+ Retrieves the details of an existing code execution context (session) by id.
+ Returns the context ID, language, and any associated metadata.
+ operationId: getContext
+ tags:
+ - CodeInterpreting
+ parameters:
+ - name: context_id
+ in: path
+ required: true
+ description: Session/context id to get
+ schema:
+ type: string
+ example: session-abc123
+ responses:
+ "200":
+ description: Context details retrieved successfully
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CodeContext"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
delete:
summary: Delete a code execution context by id
description: |
diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java
index b0d2c49b..17f1e86b 100644
--- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java
+++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java
@@ -25,11 +25,7 @@
import com.alibaba.opensandbox.sandbox.Sandbox;
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException;
import com.alibaba.opensandbox.sandbox.domain.models.execd.executions.*;
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint;
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo;
-import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics;
import java.time.Duration;
-import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -139,38 +135,6 @@ void testCodeInterpreterBasicFunctionality() {
assertNotNull(codeInterpreter.files());
assertNotNull(codeInterpreter.commands());
assertNotNull(codeInterpreter.metrics());
-
- // 3. Verify health check
- assertTrue(codeInterpreter.isHealthy());
-
- // 4. Test sandbox management operations through CodeInterpreter
- SandboxInfo info = codeInterpreter.getInfo();
- assertEquals(codeInterpreter.getId(), info.getId());
- assertEquals("Running", info.getStatus().getState());
-
- // 5. Get endpoint and metrics
- SandboxEndpoint endpoint = codeInterpreter.getEndpoint(44772);
- assertNotNull(endpoint);
- assertNotNull(endpoint.getEndpoint());
- assertEndpointHasPort(endpoint.getEndpoint(), 44772);
-
- SandboxMetrics metrics = codeInterpreter.getMetrics();
- assertNotNull(metrics);
- assertTrue(metrics.getCpuCount() > 0);
- assertTrue(
- metrics.getCpuUsedPercentage() >= 0.0 && metrics.getCpuUsedPercentage() <= 100.0);
- assertTrue(metrics.getMemoryTotalInMiB() > 0);
- assertTrue(
- metrics.getMemoryUsedInMiB() >= 0.0
- && metrics.getMemoryUsedInMiB() <= metrics.getMemoryTotalInMiB());
- assertRecentTimestampMs(metrics.getTimestamp(), 180_000);
-
- // 7. Renew and validate TTL range.
- codeInterpreter.renew(Duration.ofMinutes(5));
- SandboxInfo renewedInfo = codeInterpreter.getInfo();
- Duration remaining = Duration.between(OffsetDateTime.now(), renewedInfo.getExpiresAt());
- assertTrue(remaining.compareTo(Duration.ofMinutes(3)) > 0);
- assertTrue(remaining.compareTo(Duration.ofMinutes(7)) < 0);
}
@Test
diff --git a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java
index d0b9b9c7..cc10969c 100644
--- a/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java
+++ b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java
@@ -167,9 +167,19 @@ void testSandboxLifecycleAndHealth() {
assertRecentTimestampMs(metrics.getTimestamp(), 120_000);
// Renew: validate remaining TTL is close to requested duration.
- sandbox.renew(Duration.ofMinutes(5));
+ SandboxRenewResponse renewResp = sandbox.renew(Duration.ofMinutes(5));
+ assertNotNull(renewResp, "renew() should return a response");
+ assertNotNull(renewResp.getExpiresAt(), "renew().expiresAt should not be null");
SandboxInfo renewedInfo = sandbox.getInfo();
assertTrue(renewedInfo.getExpiresAt().isAfter(info.getExpiresAt()));
+ assertTrue(
+ renewResp.getExpiresAt().isAfter(info.getExpiresAt()),
+ "renew().expiresAt should be after previous expiresAt");
+ // Allow small skew between renew response and subsequent getInfo() (backend timing).
+ assertTrue(
+ Math.abs(Duration.between(renewResp.getExpiresAt(), renewedInfo.getExpiresAt()).toSeconds())
+ < 10,
+ "renew response expiresAt should be close to getInfo().expiresAt");
Duration remaining = Duration.between(OffsetDateTime.now(), renewedInfo.getExpiresAt());
assertTrue(
remaining.compareTo(Duration.ofMinutes(3)) > 0,
@@ -671,23 +681,19 @@ void testSandboxPause() throws InterruptedException {
void testSandboxResume() throws InterruptedException {
assertNotNull(sandbox);
- sandbox.resume();
+ Sandbox resumedSandbox =
+ Sandbox.resumer()
+ .sandboxId(sandbox.getId())
+ .connectionConfig(sharedConnectionConfig)
+ .resumeTimeout(Duration.ofMinutes(1))
+ .healthCheckPollingInterval(Duration.ofSeconds(1))
+ .resume();
int pollCount = 0;
- SandboxStatus finalStatus = null;
- while (pollCount < 60) {
- Thread.sleep(1000);
- pollCount++;
- SandboxInfo info = sandbox.getInfo();
- SandboxStatus currentStatus = info.getStatus();
- if ("Running".equals(currentStatus.getState())) {
- finalStatus = currentStatus;
- break;
- }
- }
+ SandboxStatus status = resumedSandbox.getInfo().getStatus();
- assertNotNull(finalStatus, "Failed to get final status after resume operation");
- assertEquals("Running", finalStatus.getState());
+ assertNotNull(status, "Failed to get final status after resume operation");
+ assertEquals("Running", status.getState());
boolean healthy = false;
for (int i = 0; i < 30; i++) {
diff --git a/tests/python/pyproject.toml b/tests/python/pyproject.toml
index 4b5a0426..6269975d 100644
--- a/tests/python/pyproject.toml
+++ b/tests/python/pyproject.toml
@@ -78,8 +78,22 @@ log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)s] %(name)s - %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
-[tool.ruff]
-line-length = 100
-
[tool.ruff.lint]
-select = ["E", "F", "I", "UP"]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+]
+ignore = [
+ "E501", # line too long, handled by formatter
+ "B008", # do not perform function calls in argument defaults
+ "C901", # too complex
+ "B017", # pytest.raises(Exception) is too broad
+]
+
+[tool.ruff.lint.per-file-ignores]
+"__init__.py" = ["F401"]
diff --git a/tests/python/tests/test_code_interpreter_e2e.py b/tests/python/tests/test_code_interpreter_e2e.py
index 3390b59f..aae7cf8d 100644
--- a/tests/python/tests/test_code_interpreter_e2e.py
+++ b/tests/python/tests/test_code_interpreter_e2e.py
@@ -34,7 +34,6 @@
from uuid import UUID
import pytest
-from tests.base_e2e_test import create_connection_config, get_sandbox_image
from code_interpreter import CodeInterpreter
from code_interpreter.models.code import SupportedLanguage
from opensandbox import Sandbox
@@ -51,6 +50,8 @@
)
from opensandbox.models.sandboxes import SandboxImageSpec
+from tests.base_e2e_test import create_connection_config, get_sandbox_image
+
logger = logging.getLogger(__name__)
@@ -189,10 +190,10 @@ async def test_01_creation_and_basic_functionality(self):
assert code_interpreter.metrics is not None
logger.info("✓ All service components are accessible")
- assert await code_interpreter.is_healthy() is True
+ assert await code_interpreter.sandbox.is_healthy() is True
logger.info("✓ CodeInterpreter is healthy")
- info = await code_interpreter.get_info()
+ info = await code_interpreter.sandbox.get_info()
assert str(code_interpreter.id) == str(info.id)
assert info.status.state == "Running"
logger.info(
@@ -201,13 +202,13 @@ async def test_01_creation_and_basic_functionality(self):
info.created_at,
)
- endpoint = await code_interpreter.get_endpoint(DEFAULT_EXECD_PORT)
+ endpoint = await code_interpreter.sandbox.get_endpoint(DEFAULT_EXECD_PORT)
assert endpoint is not None
assert endpoint.endpoint is not None
_assert_endpoint_has_port(endpoint.endpoint, DEFAULT_EXECD_PORT)
logger.info("✓ CodeInterpreter endpoint: %s", endpoint.endpoint)
- metrics = await code_interpreter.get_metrics()
+ metrics = await code_interpreter.sandbox.get_metrics()
assert metrics is not None
assert metrics.cpu_count > 0
assert 0.0 <= metrics.cpu_used_percentage <= 100.0
@@ -226,10 +227,12 @@ async def test_01_creation_and_basic_functionality(self):
)
# Renewal through CodeInterpreter (extend expiration time)
- await code_interpreter.renew(timedelta(minutes=5))
- logger.info("✓ CodeInterpreter expiration renewed")
+ renew_response = await code_interpreter.sandbox.renew(timedelta(minutes=5))
+ assert renew_response is not None
+ logger.info("✓ CodeInterpreter expiration renewed to %s", renew_response.expires_at)
- renewed_info = await code_interpreter.get_info()
+ renewed_info = await code_interpreter.sandbox.get_info()
+ assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10
now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)
remaining = renewed_info.expires_at - now
assert remaining > timedelta(minutes=3)
@@ -380,6 +383,18 @@ async def test_03_python_code_execution(self):
logger.info("TEST 3: Python code execution")
logger.info("=" * 80)
+ # New usage: directly pass a language string (ephemeral context).
+ # This validates the `codes.run(..., language=...)` convenience interface.
+ direct_lang_result = await code_interpreter.codes.run(
+ "result = 2 + 2\nresult",
+ language=SupportedLanguage.PYTHON,
+ )
+ assert direct_lang_result is not None
+ assert direct_lang_result.id is not None and direct_lang_result.id.strip()
+ assert direct_lang_result.error is None
+ assert len(direct_lang_result.result) > 0
+ assert direct_lang_result.result[0].text == "4"
+
stdout_messages: list[OutputMessage] = []
stderr_messages: list[OutputMessage] = []
errors: list[ExecutionError] = []
diff --git a/tests/python/tests/test_code_interpreter_e2e_sync.py b/tests/python/tests/test_code_interpreter_e2e_sync.py
index 86f4d441..1552e1a1 100644
--- a/tests/python/tests/test_code_interpreter_e2e_sync.py
+++ b/tests/python/tests/test_code_interpreter_e2e_sync.py
@@ -26,7 +26,6 @@
from uuid import UUID
import pytest
-from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image
from code_interpreter import CodeInterpreterSync
from code_interpreter.models.code import SupportedLanguage
from opensandbox import SandboxSync
@@ -43,6 +42,8 @@
from opensandbox.models.execd_sync import ExecutionHandlersSync
from opensandbox.models.sandboxes import SandboxImageSpec
+from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image
+
logger = logging.getLogger(__name__)
@@ -171,18 +172,18 @@ def test_01_creation_and_basic_functionality(self):
assert code_interpreter.commands is not None
assert code_interpreter.metrics is not None
- assert code_interpreter.is_healthy() is True
+ assert code_interpreter.sandbox.is_healthy() is True
- info = code_interpreter.get_info()
+ info = code_interpreter.sandbox.get_info()
assert str(code_interpreter.id) == str(info.id)
assert info.status.state == "Running"
- endpoint = code_interpreter.get_endpoint(DEFAULT_EXECD_PORT)
+ endpoint = code_interpreter.sandbox.get_endpoint(DEFAULT_EXECD_PORT)
assert endpoint is not None
assert endpoint.endpoint is not None
_assert_endpoint_has_port(endpoint.endpoint, DEFAULT_EXECD_PORT)
- metrics = code_interpreter.get_metrics()
+ metrics = code_interpreter.sandbox.get_metrics()
assert metrics is not None
assert metrics.cpu_count > 0
assert 0.0 <= metrics.cpu_used_percentage <= 100.0
@@ -190,8 +191,10 @@ def test_01_creation_and_basic_functionality(self):
assert 0.0 <= metrics.memory_used_in_mib <= metrics.memory_total_in_mib
_assert_recent_timestamp_ms(metrics.timestamp)
- code_interpreter.renew(timedelta(minutes=5))
- renewed_info = code_interpreter.get_info()
+ renew_response = code_interpreter.sandbox.renew(timedelta(minutes=5))
+ assert renew_response is not None
+ renewed_info = code_interpreter.sandbox.get_info()
+ assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10
now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)
remaining = renewed_info.expires_at - now
assert remaining > timedelta(minutes=3)
@@ -317,6 +320,18 @@ def test_03_python_code_execution(self):
code_interpreter = TestCodeInterpreterE2ESync.code_interpreter
assert code_interpreter is not None
+ # New usage: directly pass a language string (ephemeral context).
+ # This validates the `codes.run(..., language=...)` convenience interface.
+ direct_lang_result = code_interpreter.codes.run(
+ "result = 2 + 2\nresult",
+ language=SupportedLanguage.PYTHON,
+ )
+ assert direct_lang_result is not None
+ assert direct_lang_result.id is not None and direct_lang_result.id.strip()
+ assert direct_lang_result.error is None
+ assert len(direct_lang_result.result) > 0
+ assert direct_lang_result.result[0].text == "4"
+
stdout_messages: list[OutputMessage] = []
stderr_messages: list[OutputMessage] = []
errors: list[ExecutionError] = []
@@ -688,16 +703,6 @@ def run_python1():
context=python_c1,
)
- def run_python2():
- return code_interpreter.codes.run(
- "import time\n"
- + "for i in range(3):\n"
- + " print(f'Python2 iteration {i}')\n"
- + " time.sleep(0.1)\n"
- + "print('Python2 completed')",
- context=python_c2,
- )
-
def run_java_concurrent():
return code_interpreter.codes.run(
"for (int i = 0; i < 3; i++) {\n"
diff --git a/tests/python/tests/test_sandbox_e2e.py b/tests/python/tests/test_sandbox_e2e.py
index 168106b0..3e089595 100644
--- a/tests/python/tests/test_sandbox_e2e.py
+++ b/tests/python/tests/test_sandbox_e2e.py
@@ -25,7 +25,6 @@
from uuid import UUID
import pytest
-from tests.base_e2e_test import create_connection_config, get_sandbox_image
from opensandbox import Sandbox
from opensandbox.models.execd import (
ExecutionComplete,
@@ -45,6 +44,8 @@
)
from opensandbox.models.sandboxes import SandboxImageSpec
+from tests.base_e2e_test import create_connection_config, get_sandbox_image
+
logger = logging.getLogger(__name__)
@@ -229,14 +230,19 @@ async def test_01_sandbox_lifecycle_and_health(self):
)
logger.info("Step 5: Test sandbox renewal (extend expiration time)")
- await sandbox.renew(timedelta(minutes=5))
- logger.info("✓ Sandbox expiration renewed")
+ renew_response = await sandbox.renew(timedelta(minutes=5))
+ assert renew_response is not None
+ assert renew_response.expires_at > info.expires_at
+ logger.info("✓ Sandbox expiration renewed to %s", renew_response.expires_at)
renewed_info = await sandbox.get_info()
assert renewed_info.expires_at > info.expires_at
assert renewed_info.id == sandbox.id
assert renewed_info.status.state == "Running"
+ # The renew API should return the new expiration time. Allow small backend-side skew.
+ assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10
+
# Renewal is "now + timeout" (SDK behavior). Validate remaining TTL is close to 5 minutes.
now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)
remaining = renewed_info.expires_at - now
@@ -788,7 +794,13 @@ async def test_06_sandbox_resume(self):
logger.info("=" * 80)
logger.info("Requesting sandbox resume...")
- await sandbox.resume()
+ resumed = await Sandbox.resume(
+ sandbox_id=sandbox.id,
+ connection_config=TestSandboxE2E.connection_config,
+ )
+ # Replace the class-held instance so subsequent operations/teardown use the resumed instance.
+ TestSandboxE2E.sandbox = resumed
+ sandbox = resumed
start_time = time.time()
poll_count = 0
@@ -819,6 +831,13 @@ async def test_06_sandbox_resume(self):
await asyncio.sleep(1)
assert healthy is True, "Sandbox should be healthy after resume"
+ # Minimal smoke check: after resume, the existing Sandbox instance should still be usable.
+ # This helps validate that SDK re-bound its execd adapters (endpoint may change across resume).
+ echo = await sandbox.commands.run("echo resume-ok")
+ assert echo.error is None
+ assert len(echo.logs.stdout) == 1
+ assert echo.logs.stdout[0].text == "resume-ok"
+
elapsed_time = (time.time() - start_time) * 1000
logger.info(f"✓ Sandbox resume completed in {elapsed_time:.2f} ms")
logger.info("TEST 5 PASSED: Sandbox resume operation test completed successfully")
diff --git a/tests/python/tests/test_sandbox_e2e_sync.py b/tests/python/tests/test_sandbox_e2e_sync.py
index 702e60e1..009f6214 100644
--- a/tests/python/tests/test_sandbox_e2e_sync.py
+++ b/tests/python/tests/test_sandbox_e2e_sync.py
@@ -27,7 +27,6 @@
from uuid import UUID
import pytest
-from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image
from opensandbox import SandboxSync
from opensandbox.models.execd import (
ExecutionComplete,
@@ -46,6 +45,8 @@
)
from opensandbox.models.sandboxes import SandboxImageSpec
+from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image
+
logger = logging.getLogger(__name__)
@@ -209,10 +210,13 @@ def test_01_sandbox_lifecycle_and_health(self) -> None:
_assert_recent_timestamp_ms(metrics.timestamp, tolerance_ms=120_000)
await_renew = timedelta(minutes=5)
- sandbox.renew(await_renew)
+ renew_response = sandbox.renew(await_renew)
+ assert renew_response is not None
+ assert renew_response.expires_at > info.expires_at
renewed_info = sandbox.get_info()
assert renewed_info.expires_at > info.expires_at
+ assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10
now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)
remaining = renewed_info.expires_at - now
@@ -670,7 +674,12 @@ def test_06_sandbox_resume(self) -> None:
logger.info("TEST 6: Testing sandbox resume operation (sync)")
logger.info("=" * 80)
- sandbox.resume()
+ resumed = SandboxSync.resume(
+ sandbox_id=sandbox.id,
+ connection_config=TestSandboxE2ESync.connection_config,
+ )
+ TestSandboxE2ESync.sandbox = resumed
+ sandbox = resumed
poll_count = 0
final_status = None
@@ -693,3 +702,9 @@ def test_06_sandbox_resume(self) -> None:
break
time.sleep(1)
assert healthy is True, "Sandbox should be healthy after resume"
+
+ # Minimal smoke check: after resume, the existing SandboxSync instance should still be usable.
+ echo = sandbox.commands.run("echo resume-ok")
+ assert echo.error is None
+ assert len(echo.logs.stdout) == 1
+ assert echo.logs.stdout[0].text == "resume-ok"
diff --git a/tests/python/tests/test_sandbox_manager_e2e.py b/tests/python/tests/test_sandbox_manager_e2e.py
index 4e2caa12..08a41301 100644
--- a/tests/python/tests/test_sandbox_manager_e2e.py
+++ b/tests/python/tests/test_sandbox_manager_e2e.py
@@ -30,15 +30,15 @@
from uuid import uuid4
import pytest
-from opensandbox.config import ConnectionConfig
-
-from tests.base_e2e_test import create_connection_config, get_sandbox_image
from opensandbox import Sandbox, SandboxManager
+from opensandbox.config import ConnectionConfig
from opensandbox.models.sandboxes import (
SandboxFilter,
SandboxImageSpec,
)
+from tests.base_e2e_test import create_connection_config, get_sandbox_image
+
logger = logging.getLogger(__name__)
diff --git a/tests/python/tests/test_sandbox_manager_e2e_sync.py b/tests/python/tests/test_sandbox_manager_e2e_sync.py
index a7d8d35a..0e16b434 100644
--- a/tests/python/tests/test_sandbox_manager_e2e_sync.py
+++ b/tests/python/tests/test_sandbox_manager_e2e_sync.py
@@ -28,10 +28,11 @@
from uuid import uuid4
import pytest
-from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image
from opensandbox import SandboxManagerSync, SandboxSync
from opensandbox.models.sandboxes import SandboxFilter, SandboxImageSpec
+from tests.base_e2e_test import create_connection_config_sync, get_sandbox_image
+
class TestSandboxManagerE2ESync:
@pytest.mark.timeout(600)
diff --git a/tests/python/uv.lock b/tests/python/uv.lock
index 2f90d631..6e6d56e2 100644
--- a/tests/python/uv.lock
+++ b/tests/python/uv.lock
@@ -139,7 +139,7 @@ wheels = [
[[package]]
name = "opensandbox"
-source = { editable = "../../../sdks/sandbox/python" }
+source = { editable = "../../sdks/sandbox/python" }
dependencies = [
{ name = "attrs" },
{ name = "httpx" },
@@ -167,7 +167,7 @@ dev = [
[[package]]
name = "opensandbox-code-interpreter"
-source = { editable = "../../../sdks/code-interpreter/python" }
+source = { editable = "../../sdks/code-interpreter/python" }
dependencies = [
{ name = "opensandbox" },
{ name = "pydantic" },
@@ -175,7 +175,7 @@ dependencies = [
[package.metadata]
requires-dist = [
- { name = "opensandbox", editable = "../../../sdks/sandbox/python" },
+ { name = "opensandbox", editable = "../../sdks/sandbox/python" },
{ name = "pydantic", specifier = ">=2.0.0,<3.0" },
]
@@ -210,8 +210,8 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "opensandbox", editable = "../../../sdks/sandbox/python" },
- { name = "opensandbox-code-interpreter", editable = "../../../sdks/code-interpreter/python" },
+ { name = "opensandbox", editable = "../../sdks/sandbox/python" },
+ { name = "opensandbox-code-interpreter", editable = "../../sdks/code-interpreter/python" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },