From 424a5089099f3c0172881aad661683c9cbdb54d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Fri, 26 Dec 2025 14:44:50 +0800 Subject: [PATCH 01/19] chore(sdk): update kotlin sdk project version to 1.0.1 --- sdks/code-interpreter/kotlin/gradle.properties | 2 +- sdks/code-interpreter/kotlin/gradle/libs.versions.toml | 2 +- sdks/sandbox/kotlin/gradle.properties | 2 +- .../com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/config/ConnectionConfig.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt index fdcbb1f4..696931bd 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() From 067c0725bb7579f6ea8a219e9f4636c8eac581ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Fri, 26 Dec 2025 15:00:04 +0800 Subject: [PATCH 02/19] refactor(sdk): refactor resuming sandbox --- .../codeinterpreter/CodeInterpreter.kt | 13 -- .../codeinterpreter/CodeInterpreterTest.kt | 9 - .../alibaba/opensandbox/sandbox/Sandbox.kt | 217 ++++++++++++++++-- .../opensandbox/sandbox/SandboxTest.kt | 9 - .../adapters/service/SandboxesAdapterTest.kt | 6 +- 5 files changed, 203 insertions(+), 51 deletions(-) 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..03eb6fe3 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 @@ -248,19 +248,6 @@ class CodeInterpreter internal constructor( 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. 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..c33273a1 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 @@ -146,15 +146,6 @@ class CodeInterpreterTest { 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 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..ad430893 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 @@ -140,6 +140,9 @@ class Sandbox internal constructor( @JvmStatic fun connector(): Connector = Connector() + @JvmStatic + fun resumer(): Resumer = Resumer() + /** * Creates a sandbox instance with the provided configuration. * @@ -255,7 +258,7 @@ class Sandbox internal constructor( connectionConfig: ConnectionConfig, healthCheck: ((Sandbox) -> Boolean)? = null, ): Sandbox { - logger.info("Connecting to existing sandbox: {}", sandboxId) + logger.info("Connecting to running sandbox: {}", sandboxId) val httpClientProvider = HttpClientProvider(connectionConfig) val factory = AdapterFactory(httpClientProvider) @@ -303,6 +306,76 @@ class Sandbox internal constructor( } } } + + /** + * 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 healthPollingInterval 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, + healthPollingInterval: Duration, + ): Sandbox { + logger.info("Resume sandbox: {}", sandboxId) + val httpClientProvider = HttpClientProvider(connectionConfig) + val factory = AdapterFactory(httpClientProvider) + + try { + val sandboxService = factory.createSandboxes() + sandboxService.resumeSandbox(sandboxId) + + 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, + ) + + sandbox.checkReady(resumeTimeout, healthPollingInterval) + + logger.info("Sandbox {} resumed", sandbox.id) + + return sandbox + } catch (e: Exception) { + httpClientProvider.close() + when (e) { + is SandboxException -> throw e + else -> { + logger.error("Unexpected exception during sandbox resume", e) + throw SandboxInternalException( + message = "Failed to resume sandbox: ${e.message}", + cause = e, + ) + } + } + } + } } /** @@ -360,19 +433,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 +533,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) } @@ -957,4 +1017,127 @@ class Sandbox internal constructor( ) } } + + /** + * 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) + + /** + * 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 + } + + /** + * 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, + healthPollingInterval = healthCheckPollingInterval, + ) + } + } } 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..dbb99a5c 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 @@ -153,15 +153,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 From 87fbde8ff390d398c47065f3eb5d03e1da2548c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Fri, 26 Dec 2025 16:17:35 +0800 Subject: [PATCH 03/19] refactor(sdk): refactor sandbox initialization --- .../alibaba/opensandbox/sandbox/Sandbox.kt | 282 +++++++++--------- 1 file changed, 142 insertions(+), 140 deletions(-) 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 ad430893..c2366a12 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,7 +21,6 @@ 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 @@ -144,57 +143,50 @@ class Sandbox internal constructor( 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, + 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) @@ -202,7 +194,7 @@ class Sandbox internal constructor( val sandbox = Sandbox( - id = response.id, + id = sandboxId, sandboxService = sandboxService, fileSystemService = fileSystemService, commandService = commandService, @@ -212,19 +204,20 @@ class Sandbox internal constructor( httpClientProvider = httpClientProvider, ) - sandbox.checkReady(readyTimeout, healthCheckPollingInterval) - - logger.info("Sandbox {} is ready and available for use", sandbox.id) + sandbox.checkReady(timeout, healthCheckPollingInterval) + logger.info("{} operation completed for sandbox {}", 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) } } @@ -233,9 +226,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, ) } @@ -243,6 +236,57 @@ 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, + ): Sandbox { + return initializeSandbox( + operationName = "create sandbox with image ${imageSpec.image} (timeout: ${timeout.seconds}s)", + connectionConfig = connectionConfig, + healthCheck = healthCheck, + timeout = readyTimeout, + healthCheckPollingInterval = healthCheckPollingInterval, + ) { sandboxService -> + val response = + sandboxService.createSandbox( + imageSpec, + entrypoint, + env, + metadata, + timeout, + resource, + extensions, + ) + InitializationResult.NewSandbox(response.id) + } + } + /** * Connects to an existing sandbox instance by ID. * @@ -257,53 +301,17 @@ class Sandbox internal constructor( sandboxId: UUID, connectionConfig: ConnectionConfig, healthCheck: ((Sandbox) -> Boolean)? = null, + connectTimeout: Duration, + healthCheckPollingInterval: Duration, ): Sandbox { - logger.info("Connecting to running 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 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, - ) - } - } + return initializeSandbox( + operationName = "connect to sandbox $sandboxId", + connectionConfig = connectionConfig, + healthCheck = healthCheck, + timeout = connectTimeout, + healthCheckPollingInterval = healthCheckPollingInterval, + ) { _ -> + InitializationResult.ExistingSandbox(sandboxId) } } @@ -320,7 +328,7 @@ class Sandbox internal constructor( * @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 healthPollingInterval Polling interval for readiness/health check + * @param healthCheckPollingInterval Polling interval for readiness/health check * @return Resumed and ready Sandbox instance * @throws SandboxException if resume or readiness check fails */ @@ -329,51 +337,17 @@ class Sandbox internal constructor( connectionConfig: ConnectionConfig, healthCheck: ((Sandbox) -> Boolean)? = null, resumeTimeout: Duration, - healthPollingInterval: Duration, + healthCheckPollingInterval: Duration, ): Sandbox { - logger.info("Resume sandbox: {}", sandboxId) - val httpClientProvider = HttpClientProvider(connectionConfig) - val factory = AdapterFactory(httpClientProvider) - - try { - val sandboxService = factory.createSandboxes() + return initializeSandbox( + operationName = "resume sandbox $sandboxId", + connectionConfig = connectionConfig, + healthCheck = healthCheck, + timeout = resumeTimeout, + healthCheckPollingInterval = healthCheckPollingInterval, + ) { sandboxService -> sandboxService.resumeSandbox(sandboxId) - - 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, - ) - - sandbox.checkReady(resumeTimeout, healthPollingInterval) - - logger.info("Sandbox {} resumed", sandbox.id) - - return sandbox - } catch (e: Exception) { - httpClientProvider.close() - when (e) { - is SandboxException -> throw e - else -> { - logger.error("Unexpected exception during sandbox resume", e) - throw SandboxInternalException( - message = "Failed to resume sandbox: ${e.message}", - cause = e, - ) - } - } + InitializationResult.ExistingSandbox(sandboxId) } } } @@ -583,6 +557,16 @@ 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) + /** * Sets the sandbox ID to connect to. * @@ -605,6 +589,22 @@ 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 + } + /** * Connects to the existing sandbox with the configured parameters. * @@ -627,6 +627,8 @@ class Sandbox internal constructor( sandboxId = id, connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(), healthCheck = healthCheck, + connectTimeout = connectTimeout, + healthCheckPollingInterval = healthCheckPollingInterval, ) } } @@ -1136,7 +1138,7 @@ class Sandbox internal constructor( connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(), healthCheck = healthCheck, resumeTimeout = resumeTimeout, - healthPollingInterval = healthCheckPollingInterval, + healthCheckPollingInterval = healthCheckPollingInterval, ) } } From e5a6e76257b2493fcbdffc61b4c59091343352ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Fri, 26 Dec 2025 16:50:20 +0800 Subject: [PATCH 04/19] test: fix java e2e test after sdk refactoring --- .../opensandbox/e2e/SandboxE2ETest.java | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) 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..ff69eafb 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 @@ -671,23 +671,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++) { From 4c045d3fd1046e63cf74bcbbf7ba6611e09c310e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 10:30:39 +0800 Subject: [PATCH 05/19] feat(sdk): support skipping health check --- .../alibaba/opensandbox/sandbox/Sandbox.kt | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) 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 c2366a12..19751920 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 @@ -171,6 +171,7 @@ class Sandbox internal constructor( healthCheck: ((Sandbox) -> Boolean)?, timeout: Duration, healthCheckPollingInterval: Duration, + skipHealthCheck: Boolean, initAction: (Sandboxes) -> InitializationResult, ): Sandbox { logger.info("Starting {} operation", operationName) @@ -204,8 +205,16 @@ class Sandbox internal constructor( httpClientProvider = httpClientProvider, ) - sandbox.checkReady(timeout, healthCheckPollingInterval) - logger.info("{} operation completed for sandbox {}", operationName, sandboxId) + 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) { @@ -265,6 +274,7 @@ class Sandbox internal constructor( healthCheck: ((Sandbox) -> Boolean)? = null, healthCheckPollingInterval: Duration, extensions: Map, + skipHealthCheck: Boolean, ): Sandbox { return initializeSandbox( operationName = "create sandbox with image ${imageSpec.image} (timeout: ${timeout.seconds}s)", @@ -272,6 +282,7 @@ class Sandbox internal constructor( healthCheck = healthCheck, timeout = readyTimeout, healthCheckPollingInterval = healthCheckPollingInterval, + skipHealthCheck = skipHealthCheck, ) { sandboxService -> val response = sandboxService.createSandbox( @@ -303,6 +314,7 @@ class Sandbox internal constructor( healthCheck: ((Sandbox) -> Boolean)? = null, connectTimeout: Duration, healthCheckPollingInterval: Duration, + skipHealthCheck: Boolean, ): Sandbox { return initializeSandbox( operationName = "connect to sandbox $sandboxId", @@ -310,6 +322,7 @@ class Sandbox internal constructor( healthCheck = healthCheck, timeout = connectTimeout, healthCheckPollingInterval = healthCheckPollingInterval, + skipHealthCheck = skipHealthCheck, ) { _ -> InitializationResult.ExistingSandbox(sandboxId) } @@ -338,6 +351,7 @@ class Sandbox internal constructor( healthCheck: ((Sandbox) -> Boolean)? = null, resumeTimeout: Duration, healthCheckPollingInterval: Duration, + skipHealthCheck: Boolean, ): Sandbox { return initializeSandbox( operationName = "resume sandbox $sandboxId", @@ -345,6 +359,7 @@ class Sandbox internal constructor( healthCheck = healthCheck, timeout = resumeTimeout, healthCheckPollingInterval = healthCheckPollingInterval, + skipHealthCheck = skipHealthCheck, ) { sandboxService -> sandboxService.resumeSandbox(sandboxId) InitializationResult.ExistingSandbox(sandboxId) @@ -567,6 +582,13 @@ class Sandbox internal constructor( */ 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. * @@ -605,6 +627,14 @@ class Sandbox internal constructor( 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. * @@ -629,6 +659,7 @@ class Sandbox internal constructor( healthCheck = healthCheck, connectTimeout = connectTimeout, healthCheckPollingInterval = healthCheckPollingInterval, + skipHealthCheck = skipHealthCheck, ) } } @@ -713,6 +744,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 */ @@ -975,6 +1013,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 @@ -1016,6 +1062,7 @@ class Sandbox internal constructor( connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(), healthCheckPollingInterval = healthCheckPollingInterval, healthCheck = healthCheck, + skipHealthCheck = skipHealthCheck, ) } } @@ -1072,6 +1119,13 @@ class Sandbox internal constructor( */ 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. * @@ -1117,6 +1171,14 @@ class Sandbox internal constructor( 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. * @@ -1139,6 +1201,7 @@ class Sandbox internal constructor( healthCheck = healthCheck, resumeTimeout = resumeTimeout, healthCheckPollingInterval = healthCheckPollingInterval, + skipHealthCheck = skipHealthCheck, ) } } From 5f403b639b68d8dcbb36e8584f82e704c10e30d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 10:56:43 +0800 Subject: [PATCH 06/19] refactor(sdk): remove unnecessary delegate in code interpreter --- .../codeinterpreter/CodeInterpreter.kt | 91 ------------------- .../codeinterpreter/CodeInterpreterTest.kt | 71 --------------- 2 files changed, 162 deletions(-) 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 03eb6fe3..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,92 +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() - } - - /** - * 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/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 c33273a1..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,68 +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 `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() } - } } From 9560b58dbd27d8e7651f06b016b052a451edaa88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 11:11:15 +0800 Subject: [PATCH 07/19] feat(specs): get code context by id --- specs/execd-api.yaml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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: | From 5b66fc5ef2dbfc4f90bae60e453d8b70019df12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 11:23:58 +0800 Subject: [PATCH 08/19] feat(sdk): support code context management --- .../codeinterpreter/domain/services/Codes.kt | 39 +++++++++++++++++++ .../adapters/service/CodesAdapter.kt | 38 ++++++++++++++++++ 2 files changed, 77 insertions(+) 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..f168a454 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. * 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") From 4bcb3ce42d74db5ac942e4b5545bf769d88ba3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 11:29:58 +0800 Subject: [PATCH 09/19] test: fix java e2e test after sdk updates --- .../e2e/CodeInterpreterE2ETest.java | 36 ------------------- 1 file changed, 36 deletions(-) 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 From 1b3fbd6b0b08f42c3c5a031c24a0e86f4b458466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 18:58:44 +0800 Subject: [PATCH 10/19] refactor(sdks): refactor resume sandbox --- sdks/sandbox/python/README.md | 5 +- sdks/sandbox/python/README_zh.md | 5 +- .../sandbox/python/src/opensandbox/sandbox.py | 131 ++++++++++++++---- .../python/src/opensandbox/sync/sandbox.py | 118 +++++++++++++--- 4 files changed, 208 insertions(+), 51 deletions(-) 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/src/opensandbox/sandbox.py b/sdks/sandbox/python/src/opensandbox/sandbox.py index d7eb0cbc..792c9a6a 100644 --- a/sdks/sandbox/python/src/opensandbox/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/sandbox.py @@ -33,7 +33,6 @@ SandboxException, SandboxInternalException, SandboxReadyTimeoutException, - SandboxUnhealthyException, ) from opensandbox.models.sandboxes import ( SandboxEndpoint, @@ -228,18 +227,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 +358,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 +376,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 +397,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 +429,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 +468,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 +479,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 +499,6 @@ async def connect( config = connection_config or ConnectionConfig() logger.info(f"Connecting to sandbox: {sandbox_id}") - factory = AdapterFactory(config) try: @@ -519,22 +518,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/sync/sandbox.py b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py index 78810e77..2f58706a 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py @@ -31,7 +31,6 @@ SandboxException, SandboxInternalException, SandboxReadyTimeoutException, - SandboxUnhealthyException, ) from opensandbox.models.sandboxes import ( SandboxEndpoint, @@ -236,18 +235,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 +343,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 +361,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 +384,6 @@ def create( image.image, timeout.total_seconds(), ) - factory = AdapterFactorySync(config) sandbox_id: UUID | None = None sandbox_service: SandboxesSync | None = None @@ -419,13 +407,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 +438,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 +449,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 +483,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 +500,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 From 7e73836f863aeefcdc832f1f8c367a704318959e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 19:01:28 +0800 Subject: [PATCH 11/19] feat(sdk): support code context management --- .../code_interpreter/adapters/code_adapter.py | 65 +++++++++++++++++ .../src/code_interpreter/services/code.py | 42 +++++++++++ .../sync/adapters/code_adapter.py | 69 +++++++++++++++++++ .../code_interpreter/sync/services/code.py | 16 +++++ 4 files changed, 192 insertions(+) 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..576e6b08 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,6 +167,71 @@ 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, 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..39b459a5 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/services/code.py +++ b/sdks/code-interpreter/python/src/code_interpreter/services/code.py @@ -91,6 +91,48 @@ 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") + """ + ... + async def run( self, code: str, 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..4beaf2ce 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 @@ -128,6 +128,75 @@ def create_context(self, language: str) -> CodeContextSync: 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 = parsed.id if parsed.id is not UNSET else None + 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, 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..a5cdb15d 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 @@ -73,6 +73,22 @@ 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).""" + ... + def run( self, code: str, From d7e7d292db8268e1338a8ffadfee25f3d87dd14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 19:02:11 +0800 Subject: [PATCH 12/19] refactor(sdk): remove unnecessary delegate in code interpreter --- .../src/code_interpreter/code_interpreter.py | 129 +----------------- .../code_interpreter/sync/code_interpreter.py | 111 +-------------- 2 files changed, 4 insertions(+), 236 deletions(-) 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/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": """ From 442d81f6d3ebd95414a1dde789d7987b1f253ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 19:03:14 +0800 Subject: [PATCH 13/19] docs(sdk): update docs after refactor --- examples/code-interpreter/main.py | 2 +- sdks/code-interpreter/kotlin/README.md | 2 +- sdks/code-interpreter/kotlin/README_zh.md | 2 +- sdks/code-interpreter/python/README.md | 4 ++-- sdks/code-interpreter/python/README_zh.md | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/code-interpreter/main.py b/examples/code-interpreter/main.py index 67d9080c..b763bf13 100644 --- a/examples/code-interpreter/main.py +++ b/examples/code-interpreter/main.py @@ -110,7 +110,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..1595f1f4 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()); diff --git a/sdks/code-interpreter/kotlin/README_zh.md b/sdks/code-interpreter/kotlin/README_zh.md index a24b5bc6..f350d597 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()); diff --git a/sdks/code-interpreter/python/README.md b/sdks/code-interpreter/python/README.md index 61cf229e..ecb231fa 100644 --- a/sdks/code-interpreter/python/README.md +++ b/sdks/code-interpreter/python/README.md @@ -82,7 +82,7 @@ async def main() -> None: print(result.result[0].text) # 8. Cleanup remote instance (optional but recommended) - await interpreter.kill() + await sandbox.kill() if __name__ == "__main__": @@ -119,7 +119,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 diff --git a/sdks/code-interpreter/python/README_zh.md b/sdks/code-interpreter/python/README_zh.md index 14b17819..559fc176 100644 --- a/sdks/code-interpreter/python/README_zh.md +++ b/sdks/code-interpreter/python/README_zh.md @@ -78,7 +78,7 @@ async def main() -> None: print(result.result[0].text) # 8. 清理远程实例(可选,但推荐) - await interpreter.kill() + await sandbox.kill() if __name__ == "__main__": @@ -115,7 +115,7 @@ with sandbox: result = interpreter.codes.run("result = 2 + 2\nresult") if result.result: print(result.result[0].text) - interpreter.kill() + sandbox.kill() ``` ## 运行时配置 From 2dbada5b10fe0ee5b94ece557ac0cb6aa06930c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 29 Dec 2025 19:11:40 +0800 Subject: [PATCH 14/19] test: update test after refactor sdk --- tests/python/pyproject.toml | 22 +++++++++++++--- .../python/tests/test_code_interpreter_e2e.py | 15 +++++------ .../tests/test_code_interpreter_e2e_sync.py | 25 ++++++------------- tests/python/tests/test_sandbox_e2e.py | 18 +++++++++++-- tests/python/tests/test_sandbox_e2e_sync.py | 16 ++++++++++-- .../python/tests/test_sandbox_manager_e2e.py | 6 ++--- .../tests/test_sandbox_manager_e2e_sync.py | 3 ++- tests/python/uv.lock | 10 ++++---- 8 files changed, 74 insertions(+), 41 deletions(-) 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..272aea32 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,10 @@ async def test_01_creation_and_basic_functionality(self): ) # Renewal through CodeInterpreter (extend expiration time) - await code_interpreter.renew(timedelta(minutes=5)) + await code_interpreter.sandbox.renew(timedelta(minutes=5)) logger.info("✓ CodeInterpreter expiration renewed") - renewed_info = await code_interpreter.get_info() + renewed_info = await code_interpreter.sandbox.get_info() now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo) remaining = renewed_info.expires_at - now assert remaining > timedelta(minutes=3) diff --git a/tests/python/tests/test_code_interpreter_e2e_sync.py b/tests/python/tests/test_code_interpreter_e2e_sync.py index 86f4d441..8b0295dc 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,8 @@ 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() + code_interpreter.sandbox.renew(timedelta(minutes=5)) + renewed_info = code_interpreter.sandbox.get_info() now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo) remaining = renewed_info.expires_at - now assert remaining > timedelta(minutes=3) @@ -688,16 +689,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..dac94520 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__) @@ -788,7 +789,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 +826,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..0471ea53 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__) @@ -670,7 +671,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 +699,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" }, From fbe66e2db023fb9a1bcc4fc0da6270e4faa65ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Tue, 30 Dec 2025 11:50:18 +0800 Subject: [PATCH 15/19] feat(sdk): add return value for sandbox renew operation --- .../alibaba/opensandbox/sandbox/Sandbox.kt | 5 +++-- .../opensandbox/sandbox/SandboxManager.kt | 5 +++-- .../domain/models/sandboxes/SandboxModels.kt | 9 ++++++++ .../sandbox/domain/services/Sandboxes.kt | 5 ++++- .../converter/SandboxModelConverter.kt | 8 +++++++ .../adapters/service/SandboxesAdapter.kt | 18 ++++++++++------ .../opensandbox/sandbox/SandboxManagerTest.kt | 9 +++++--- .../opensandbox/sandbox/SandboxTest.kt | 8 ++++--- .../converter/sandbox_model_converter.py | 21 ++++++++++++++++++- .../opensandbox/adapters/sandboxes_adapter.py | 19 +++++++++++++++-- .../sandbox/python/src/opensandbox/manager.py | 7 +++++-- .../src/opensandbox/models/sandboxes.py | 13 ++++++++++++ .../sandbox/python/src/opensandbox/sandbox.py | 8 +++++-- .../src/opensandbox/services/sandbox.py | 6 +++++- .../sync/adapters/sandboxes_adapter.py | 14 ++++++++++++- .../python/src/opensandbox/sync/manager.py | 5 +++-- .../python/src/opensandbox/sync/sandbox.py | 8 +++++-- .../src/opensandbox/sync/services/sandbox.py | 8 ++++++- .../opensandbox/e2e/SandboxE2ETest.java | 12 ++++++++++- .../python/tests/test_code_interpreter_e2e.py | 6 ++++-- .../tests/test_code_interpreter_e2e_sync.py | 4 +++- tests/python/tests/test_sandbox_e2e.py | 9 ++++++-- tests/python/tests/test_sandbox_e2e_sync.py | 5 ++++- 23 files changed, 174 insertions(+), 38 deletions(-) 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 19751920..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 @@ -26,6 +26,7 @@ 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 @@ -404,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)) } /** 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/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 dbb99a5c..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 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..60cfd81f 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, 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/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py index 24b745b8..e592819e 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. diff --git a/sdks/sandbox/python/src/opensandbox/sandbox.py b/sdks/sandbox/python/src/opensandbox/sandbox.py index 792c9a6a..8839e431 100644 --- a/sdks/sandbox/python/src/opensandbox/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/sandbox.py @@ -39,6 +39,7 @@ SandboxImageSpec, SandboxInfo, SandboxMetrics, + SandboxRenewResponse, ) from opensandbox.services import ( Commands, @@ -195,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. @@ -204,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 """ @@ -212,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: """ 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/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 2f58706a..34f1d819 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py @@ -37,6 +37,7 @@ SandboxImageSpec, SandboxInfo, SandboxMetrics, + SandboxRenewResponse, ) from opensandbox.sync.adapters.factory import AdapterFactorySync from opensandbox.sync.services import ( @@ -201,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. @@ -210,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 """ @@ -220,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: """ 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/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java b/tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java index ff69eafb..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, diff --git a/tests/python/tests/test_code_interpreter_e2e.py b/tests/python/tests/test_code_interpreter_e2e.py index 272aea32..f57c4dfd 100644 --- a/tests/python/tests/test_code_interpreter_e2e.py +++ b/tests/python/tests/test_code_interpreter_e2e.py @@ -227,10 +227,12 @@ async def test_01_creation_and_basic_functionality(self): ) # Renewal through CodeInterpreter (extend expiration time) - await code_interpreter.sandbox.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.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) diff --git a/tests/python/tests/test_code_interpreter_e2e_sync.py b/tests/python/tests/test_code_interpreter_e2e_sync.py index 8b0295dc..7307d24a 100644 --- a/tests/python/tests/test_code_interpreter_e2e_sync.py +++ b/tests/python/tests/test_code_interpreter_e2e_sync.py @@ -191,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.sandbox.renew(timedelta(minutes=5)) + 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) diff --git a/tests/python/tests/test_sandbox_e2e.py b/tests/python/tests/test_sandbox_e2e.py index dac94520..3e089595 100644 --- a/tests/python/tests/test_sandbox_e2e.py +++ b/tests/python/tests/test_sandbox_e2e.py @@ -230,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 diff --git a/tests/python/tests/test_sandbox_e2e_sync.py b/tests/python/tests/test_sandbox_e2e_sync.py index 0471ea53..009f6214 100644 --- a/tests/python/tests/test_sandbox_e2e_sync.py +++ b/tests/python/tests/test_sandbox_e2e_sync.py @@ -210,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 From 1f1e320904e7a5392879a311555d2ce3345ff22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Tue, 30 Dec 2025 14:51:30 +0800 Subject: [PATCH 16/19] feat(sdk): add sandbox state --- .../python/src/opensandbox/models/__init__.py | 2 + .../src/opensandbox/models/sandboxes.py | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) 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 e592819e..928ee0ce 100644 --- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py +++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py @@ -255,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("_") + } From 712a19544e6e93421c461d97f607ed2fbacd1751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Tue, 30 Dec 2025 15:54:38 +0800 Subject: [PATCH 17/19] style(sdk): pyright check and refine code style --- sdks/code-interpreter/python/pyproject.toml | 15 +++-- .../python/src/code_interpreter/__init__.py | 6 +- .../converter/code_execution_converter.py | 4 +- .../sync/adapters/code_adapter.py | 8 +-- sdks/sandbox/python/pyproject.toml | 12 ++-- .../python/src/opensandbox/__init__.py | 6 +- .../adapters/converter/execution_converter.py | 11 ++-- .../adapters/converter/response_handler.py | 27 ++++++-- .../converter/sandbox_model_converter.py | 64 +++++++++++-------- .../adapters/filesystem_adapter.py | 47 +++++++++----- .../sync/adapters/filesystem_adapter.py | 22 +++++-- 11 files changed, 141 insertions(+), 81 deletions(-) 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/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/sync/adapters/code_adapter.py b/sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py index 4beaf2ce..b39c1db0 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,7 +122,7 @@ 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) @@ -134,7 +134,7 @@ def get_context(self, context_id: str) -> CodeContextSync: from opensandbox.api.execd.models.code_context import ( CodeContext as ApiCodeContext, ) - from opensandbox.api.execd.types import UNSET + from opensandbox.api.execd.types import Unset response_obj = get_context.sync_detailed( client=self._client, @@ -142,7 +142,7 @@ def get_context(self, context_id: str) -> CodeContextSync: ) handle_api_error(response_obj, "Get code context") parsed = require_parsed(response_obj, ApiCodeContext, "Get code context") - context_id_val = parsed.id if parsed.id is not UNSET else None + 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) 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 60cfd81f..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 @@ -188,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, @@ -196,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, @@ -265,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: @@ -277,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/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 From 6e729300a6b14e0b44c278dc43640686575a15f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Sun, 4 Jan 2026 09:51:26 +0800 Subject: [PATCH 18/19] feat(sdk): add overloaded run methods for convenience --- README.md | 18 +++---- docs/README_zh.md | 7 ++- examples/code-interpreter/main.py | 12 ++--- sdks/code-interpreter/kotlin/README.md | 15 ++++++ sdks/code-interpreter/kotlin/README_zh.md | 14 +++++ .../codeinterpreter/domain/services/Codes.kt | 54 +++++++++++++++++++ sdks/code-interpreter/python/README.md | 30 +++++++++++ sdks/code-interpreter/python/README_zh.md | 28 ++++++++++ .../code_interpreter/adapters/code_adapter.py | 12 ++++- .../src/code_interpreter/services/code.py | 27 +++++++++- .../sync/adapters/code_adapter.py | 11 +++- .../code_interpreter/sync/services/code.py | 27 +++++++++- .../test_code_service_adapter_streaming.py | 34 ++++++++++++ .../python/tests/test_code_interpreter_e2e.py | 12 +++++ .../tests/test_code_interpreter_e2e_sync.py | 12 +++++ 15 files changed, 285 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 98c74d73..bc35dbbf 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,11 @@

OpenSandbox

- [![GitHub stars](https://img.shields.io/github/stars/alibaba/OpenSandbox.svg?style=social)](https://github.com/alibaba/OpenSandbox) - [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/alibaba/OpenSandbox) - [![license](https://img.shields.io/github/license/alibaba/OpenSandbox.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) - [![PyPI version](https://badge.fury.io/py/opensandbox.svg)](https://badge.fury.io/py/opensandbox) - [![E2E Status](https://github.com/alibaba/OpenSandbox/actions/workflows/real-e2e.yml/badge.svg?branch=main)](https://github.com/alibaba/OpenSandbox/actions) - +[![GitHub stars](https://img.shields.io/github/stars/alibaba/OpenSandbox.svg?style=social)](https://github.com/alibaba/OpenSandbox) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/alibaba/OpenSandbox) +[![license](https://img.shields.io/github/license/alibaba/OpenSandbox.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) +[![PyPI version](https://badge.fury.io/py/opensandbox.svg)](https://badge.fury.io/py/opensandbox) +[![E2E Status](https://github.com/alibaba/OpenSandbox/actions/workflows/real-e2e.yml/badge.svg?branch=main)](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 @@ [![license](https://img.shields.io/github/license/alibaba/OpenSandbox.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) [![PyPI version](https://badge.fury.io/py/opensandbox.svg)](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 b763bf13..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: diff --git a/sdks/code-interpreter/kotlin/README.md b/sdks/code-interpreter/kotlin/README.md index 1595f1f4..0e8b3fc5 100644 --- a/sdks/code-interpreter/kotlin/README.md +++ b/sdks/code-interpreter/kotlin/README.md @@ -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 f350d597..78d77e09 100644 --- a/sdks/code-interpreter/kotlin/README_zh.md +++ b/sdks/code-interpreter/kotlin/README_zh.md @@ -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/domain/services/Codes.kt b/sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/services/Codes.kt index f168a454..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 @@ -99,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/python/README.md b/sdks/code-interpreter/python/README.md index ecb231fa..a77dffa5 100644 --- a/sdks/code-interpreter/python/README.md +++ b/sdks/code-interpreter/python/README.md @@ -77,6 +77,10 @@ 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) @@ -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 559fc176..d8f986f8 100644 --- a/sdks/code-interpreter/python/README_zh.md +++ b/sdks/code-interpreter/python/README_zh.md @@ -73,6 +73,9 @@ 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) @@ -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/src/code_interpreter/adapters/code_adapter.py b/sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py index 576e6b08..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 @@ -236,6 +236,7 @@ async def run( self, code: str, *, + language: str | None = None, context: CodeContext | None = None, handlers: ExecutionHandlers | None = None, ) -> Execution: @@ -249,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/services/code.py b/sdks/code-interpreter/python/src/code_interpreter/services/code.py index 39b459a5..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 @@ -133,10 +133,29 @@ async def delete_contexts(self, language: str) -> None: """ ... + @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: @@ -157,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 b39c1db0..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 @@ -201,6 +201,7 @@ def run( self, code: str, *, + language: str | None = None, context: CodeContextSync | None = None, handlers: ExecutionHandlersSync | None = None, ) -> Execution: @@ -224,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/services/code.py b/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py index a5cdb15d..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 @@ -89,10 +89,29 @@ 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: @@ -111,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/tests/python/tests/test_code_interpreter_e2e.py b/tests/python/tests/test_code_interpreter_e2e.py index f57c4dfd..aae7cf8d 100644 --- a/tests/python/tests/test_code_interpreter_e2e.py +++ b/tests/python/tests/test_code_interpreter_e2e.py @@ -383,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 7307d24a..1552e1a1 100644 --- a/tests/python/tests/test_code_interpreter_e2e_sync.py +++ b/tests/python/tests/test_code_interpreter_e2e_sync.py @@ -320,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] = [] From 25cf6fdf145ed152e0e508766499633d11ba31be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Sun, 4 Jan 2026 16:15:56 +0800 Subject: [PATCH 19/19] docs(sdk): update inline comments in connection config --- .../opensandbox/sandbox/HttpClientProvider.kt | 4 +- .../sandbox/config/ConnectionConfig.kt | 87 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) 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/config/ConnectionConfig.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt index 696931bd..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 @@ -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,