ms-enclave 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. ms_enclave/__init__.py +3 -0
  2. ms_enclave/cli/__init__.py +1 -0
  3. ms_enclave/cli/base.py +20 -0
  4. ms_enclave/cli/cli.py +27 -0
  5. ms_enclave/cli/start_server.py +84 -0
  6. ms_enclave/sandbox/__init__.py +27 -0
  7. ms_enclave/sandbox/boxes/__init__.py +16 -0
  8. ms_enclave/sandbox/boxes/base.py +274 -0
  9. ms_enclave/sandbox/boxes/docker_notebook.py +224 -0
  10. ms_enclave/sandbox/boxes/docker_sandbox.py +317 -0
  11. ms_enclave/sandbox/manager/__init__.py +11 -0
  12. ms_enclave/sandbox/manager/base.py +155 -0
  13. ms_enclave/sandbox/manager/http_manager.py +405 -0
  14. ms_enclave/sandbox/manager/local_manager.py +295 -0
  15. ms_enclave/sandbox/model/__init__.py +21 -0
  16. ms_enclave/sandbox/model/base.py +75 -0
  17. ms_enclave/sandbox/model/config.py +97 -0
  18. ms_enclave/sandbox/model/requests.py +57 -0
  19. ms_enclave/sandbox/model/responses.py +57 -0
  20. ms_enclave/sandbox/server/__init__.py +0 -0
  21. ms_enclave/sandbox/server/server.py +195 -0
  22. ms_enclave/sandbox/tools/__init__.py +4 -0
  23. ms_enclave/sandbox/tools/base.py +119 -0
  24. ms_enclave/sandbox/tools/sandbox_tool.py +46 -0
  25. ms_enclave/sandbox/tools/sandbox_tools/__init__.py +4 -0
  26. ms_enclave/sandbox/tools/sandbox_tools/file_operation.py +331 -0
  27. ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py +167 -0
  28. ms_enclave/sandbox/tools/sandbox_tools/python_executor.py +87 -0
  29. ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py +72 -0
  30. ms_enclave/sandbox/tools/tool_info.py +141 -0
  31. ms_enclave/utils/__init__.py +1 -0
  32. ms_enclave/utils/json_schema.py +208 -0
  33. ms_enclave/utils/logger.py +170 -0
  34. ms_enclave/version.py +2 -0
  35. ms_enclave-0.0.3.dist-info/METADATA +370 -0
  36. ms_enclave-0.0.3.dist-info/RECORD +40 -0
  37. ms_enclave-0.0.3.dist-info/WHEEL +5 -0
  38. ms_enclave-0.0.3.dist-info/entry_points.txt +2 -0
  39. ms_enclave-0.0.3.dist-info/licenses/LICENSE +201 -0
  40. ms_enclave-0.0.3.dist-info/top_level.txt +1 -0
ms_enclave/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .version import __release_date__, __version__
2
+
3
+ __all__ = ['__version__', '__release_date__']
@@ -0,0 +1 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
ms_enclave/cli/base.py ADDED
@@ -0,0 +1,20 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+
3
+ from abc import ABC, abstractmethod
4
+ from argparse import ArgumentParser
5
+
6
+
7
+ class CLICommand(ABC):
8
+ """
9
+ Base class for command line tool.
10
+
11
+ """
12
+
13
+ @staticmethod
14
+ @abstractmethod
15
+ def define_args(parsers: ArgumentParser):
16
+ raise NotImplementedError()
17
+
18
+ @abstractmethod
19
+ def execute(self):
20
+ raise NotImplementedError()
ms_enclave/cli/cli.py ADDED
@@ -0,0 +1,27 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+
3
+ import argparse
4
+
5
+ from ms_enclave import __version__
6
+ from ms_enclave.cli.start_server import ServerCMD
7
+
8
+
9
+ def run_cmd():
10
+ parser = argparse.ArgumentParser('MS-Enclave Command Line tool', usage='ms-enclave <command> [<args>]')
11
+ parser.add_argument('-v', '--version', action='version', version=f'ms-enclave {__version__}')
12
+ subparsers = parser.add_subparsers(help='MS-Enclave command line helper.')
13
+
14
+ ServerCMD.define_args(subparsers)
15
+
16
+ args = parser.parse_args()
17
+
18
+ if not hasattr(args, 'func'):
19
+ parser.print_help()
20
+ exit(1)
21
+
22
+ cmd = args.func(args)
23
+ cmd.execute()
24
+
25
+
26
+ if __name__ == '__main__':
27
+ run_cmd()
@@ -0,0 +1,84 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+ from argparse import ArgumentParser
3
+ from typing import Optional
4
+
5
+ from ms_enclave.cli.base import CLICommand
6
+ from ms_enclave.sandbox import create_server
7
+ from ms_enclave.utils import get_logger
8
+
9
+ logger = get_logger()
10
+
11
+
12
+ def subparser_func(args):
13
+ """ Function which will be called for a specific sub parser.
14
+ """
15
+ return ServerCMD(args)
16
+
17
+
18
+ class ServerCMD(CLICommand):
19
+ name = 'server'
20
+
21
+ def __init__(self, args):
22
+ self.args = args
23
+
24
+ @staticmethod
25
+ def define_args(parsers: ArgumentParser):
26
+ """Define args for the server command.
27
+ """
28
+ parser = parsers.add_parser(ServerCMD.name, help='Start the MS-Enclave sandbox HTTP server')
29
+ add_argument(parser)
30
+ parser.set_defaults(func=subparser_func)
31
+
32
+ def execute(self):
33
+ """Start the sandbox server using provided CLI arguments."""
34
+ cleanup_interval: int = getattr(self.args, 'cleanup_interval', 300)
35
+ host: str = getattr(self.args, 'host', '0.0.0.0')
36
+ port: int = getattr(self.args, 'port', 8000)
37
+ log_level: str = getattr(self.args, 'log_level', 'info')
38
+ api_key: Optional[str] = getattr(self.args, 'api_key', None)
39
+
40
+ server = create_server(cleanup_interval=cleanup_interval, api_key=api_key)
41
+
42
+ logger.info('Starting Sandbox Server...')
43
+ logger.info('API docs: http://%s:%d/docs', host, port)
44
+ logger.info('Health check: http://%s:%d/health', host, port)
45
+
46
+ try:
47
+ server.run(host=host, port=port, log_level=log_level)
48
+ except KeyboardInterrupt:
49
+ logger.info('Server interrupted by user, shutting down...')
50
+ except Exception as e: # pragma: no cover - runtime dependent
51
+ logger.error('Failed to start server: %s', e)
52
+ raise
53
+
54
+
55
+ def add_argument(parser: ArgumentParser) -> None:
56
+ """Register command line arguments for the server command.
57
+
58
+ Args:
59
+ parser: The argparse parser to add arguments to.
60
+ """
61
+ parser.add_argument(
62
+ '--host', type=str, default='0.0.0.0', help='Host interface to bind the server (default: 0.0.0.0)'
63
+ )
64
+ parser.add_argument('--port', type=int, default=8000, help='Port for the HTTP server (default: 8000)')
65
+ parser.add_argument(
66
+ '--log-level',
67
+ type=str,
68
+ choices=['critical', 'error', 'warning', 'info', 'debug'],
69
+ default='info',
70
+ help='Log level for the server (default: info)'
71
+ )
72
+ parser.add_argument(
73
+ '--cleanup-interval',
74
+ type=int,
75
+ default=300,
76
+ metavar='SECONDS',
77
+ help='Background cleanup interval in seconds (default: 300)'
78
+ )
79
+ parser.add_argument(
80
+ '--api-key',
81
+ type=str,
82
+ default=None,
83
+ help='Optional API key to protect endpoints. If omitted, no authentication is enforced.'
84
+ )
@@ -0,0 +1,27 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+ """Modern agent sandbox system.
3
+
4
+ A modular, extensible sandbox system for safe code execution with Docker isolation,
5
+ FastAPI-based client/server architecture, and comprehensive tool support.
6
+ """
7
+
8
+ from .boxes import DockerSandbox, Sandbox, SandboxFactory
9
+ from .manager import HttpSandboxManager, LocalSandboxManager
10
+
11
+ # Import main components
12
+ from .model import (
13
+ DockerSandboxConfig,
14
+ ExecuteCodeRequest,
15
+ ExecuteCommandRequest,
16
+ ExecutionStatus,
17
+ HealthCheckResult,
18
+ ReadFileRequest,
19
+ SandboxConfig,
20
+ SandboxInfo,
21
+ SandboxStatus,
22
+ ToolExecutionRequest,
23
+ ToolResult,
24
+ WriteFileRequest,
25
+ )
26
+ from .server.server import SandboxServer, create_server
27
+ from .tools import PythonExecutor, Tool, ToolFactory
@@ -0,0 +1,16 @@
1
+ """Sandbox implementations."""
2
+
3
+ from .base import Sandbox, SandboxFactory, register_sandbox
4
+ from .docker_notebook import DockerNotebookSandbox
5
+ from .docker_sandbox import DockerSandbox
6
+
7
+ __all__ = [
8
+ # Base interfaces
9
+ 'Sandbox',
10
+ 'SandboxFactory',
11
+ 'register_sandbox',
12
+
13
+ # Implementations
14
+ 'DockerSandbox',
15
+ 'DockerNotebookSandbox',
16
+ ]
@@ -0,0 +1,274 @@
1
+ """Base sandbox interface and factory."""
2
+
3
+ import abc
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional, Type, Union
6
+
7
+ import shortuuid as uuid
8
+
9
+ from ms_enclave.utils import get_logger
10
+
11
+ from ..model import (
12
+ CommandResult,
13
+ DockerNotebookConfig,
14
+ DockerSandboxConfig,
15
+ SandboxConfig,
16
+ SandboxInfo,
17
+ SandboxStatus,
18
+ SandboxType,
19
+ ToolResult,
20
+ )
21
+ from ..tools import Tool, ToolFactory
22
+
23
+ logger = get_logger()
24
+
25
+
26
+ class Sandbox(abc.ABC):
27
+ """Abstract base class for all sandbox implementations."""
28
+
29
+ def __init__(self, config: SandboxConfig, sandbox_id: Optional[str] = None):
30
+ """Initialize sandbox.
31
+
32
+ Args:
33
+ config: Sandbox configuration
34
+ sandbox_id: Optional sandbox ID (will be generated if not provided)
35
+ """
36
+ self.id = sandbox_id or uuid.ShortUUID(alphabet='23456789abcdefghijkmnopqrstuvwxyz').random(length=8)
37
+ self.config = config
38
+ self.status = SandboxStatus.INITIALIZING
39
+ self.created_at = datetime.now()
40
+ self.updated_at = datetime.now()
41
+ self.metadata: Dict[str, Any] = {}
42
+ self._tools: Dict[str, Tool] = {}
43
+
44
+ @property
45
+ @abc.abstractmethod
46
+ def sandbox_type(self) -> SandboxType:
47
+ """Return the sandbox type identifier."""
48
+ pass
49
+
50
+ @abc.abstractmethod
51
+ async def start(self) -> None:
52
+ """Start the sandbox environment."""
53
+ pass
54
+
55
+ @abc.abstractmethod
56
+ async def stop(self) -> None:
57
+ """Stop the sandbox environment."""
58
+ pass
59
+
60
+ @abc.abstractmethod
61
+ async def cleanup(self) -> None:
62
+ """Clean up sandbox resources."""
63
+ pass
64
+
65
+ async def initialize_tools(self) -> None:
66
+ """Initialize sandbox tools."""
67
+ for tool_name, config in self.config.tools_config.items():
68
+ try:
69
+ tool = ToolFactory.create_tool(tool_name, **config)
70
+ self.add_tool(tool)
71
+ except Exception as e:
72
+ logger.error(f'Failed to initialize tool {tool_name}: {e}')
73
+
74
+ def get_available_tools(self) -> Dict[str, Any]:
75
+ """Get list of available tools."""
76
+ return {tool.name: tool.schema for tool in self._tools.values() if tool.enabled}
77
+
78
+ def get_tool(self, tool_name: str) -> Optional[Tool]:
79
+ """Get tool instance by type.
80
+
81
+ Args:
82
+ tool_name: Tool name
83
+
84
+ Returns:
85
+ Tool instance or None if not available
86
+ """
87
+ return self._tools.get(tool_name)
88
+
89
+ def add_tool(self, tool: Tool) -> None:
90
+ """Add a tool to the sandbox.
91
+
92
+ Args:
93
+ tool: Tool instance to add
94
+ """
95
+ if tool.name in self._tools:
96
+ logger.warning(f'Tool {tool.name} is already added to the sandbox')
97
+ return
98
+ if tool.enabled:
99
+ if tool.is_compatible_with_sandbox(self.sandbox_type):
100
+ self._tools[tool.name] = tool
101
+ else:
102
+ logger.warning(
103
+ f"Tool '{tool.name}' requires sandbox type '{tool.required_sandbox_type}' "
104
+ f"but this is a '{self.sandbox_type}' sandbox. "
105
+ f'Compatible types: {SandboxType.get_compatible_types(self.sandbox_type)}'
106
+ )
107
+ else:
108
+ logger.warning(f'Tool {tool.name} is not enabled and cannot be added')
109
+
110
+ def list_tools(self) -> List[str]:
111
+ """
112
+ List all registered tools compatible with this sandbox.
113
+
114
+ Returns:
115
+ List of tool names
116
+ """
117
+ return list(self._tools.keys())
118
+
119
+ async def execute_tool(self, tool_name: str, parameters: Dict[str, Any]) -> ToolResult:
120
+ """Execute a tool with given parameters.
121
+
122
+ Args:
123
+ tool_name: Tool name
124
+ parameters: Tool parameters
125
+
126
+ Returns:
127
+ Tool execution result
128
+
129
+ Raises:
130
+ ValueError: If tool is not found or not enabled
131
+ TimeoutError: If tool execution exceeds timeout
132
+ Exception: For other execution errors
133
+ """
134
+ tool = self.get_tool(tool_name)
135
+ if not tool:
136
+ raise ValueError(f'Tool {tool_name} is not available')
137
+ if not tool.enabled:
138
+ raise ValueError(f'Tool {tool_name} is not enabled')
139
+
140
+ result = await tool.execute(sandbox_context=self, **parameters)
141
+ return result
142
+
143
+ async def execute_command(
144
+ self, command: Union[str, List[str]], timeout: Optional[int] = None, stream: bool = True
145
+ ) -> CommandResult:
146
+ """Execute a command in the sandbox environment.
147
+
148
+ Args:
149
+ command: Command to execute
150
+ timeout: Optional execution timeout in seconds
151
+ stream: Whether to stream output (if supported)
152
+ """
153
+ raise NotImplementedError('execute_command must be implemented by subclasses')
154
+
155
+ @abc.abstractmethod
156
+ async def get_execution_context(self) -> Any:
157
+ """Get the execution context for tools (e.g., container, process, etc.)."""
158
+ pass
159
+
160
+ def update_status(self, status: SandboxStatus) -> None:
161
+ """Update sandbox status.
162
+
163
+ Args:
164
+ status: New status
165
+ """
166
+ self.status = status
167
+ self.updated_at = datetime.now()
168
+
169
+ def get_info(self) -> SandboxInfo:
170
+ """Get sandbox information.
171
+
172
+ Returns:
173
+ Sandbox information
174
+ """
175
+ return SandboxInfo(
176
+ id=self.id,
177
+ status=self.status,
178
+ type=self.sandbox_type,
179
+ config=self.config.model_dump(exclude_none=True),
180
+ created_at=self.created_at,
181
+ updated_at=self.updated_at,
182
+ metadata=self.metadata,
183
+ available_tools=self.get_available_tools()
184
+ )
185
+
186
+ async def __aenter__(self):
187
+ """Async context manager entry."""
188
+ await self.start()
189
+ return self
190
+
191
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
192
+ """Async context manager exit."""
193
+ await self.stop()
194
+
195
+
196
+ class SandboxFactory:
197
+ """Factory for creating sandbox instances."""
198
+
199
+ _sandboxes: Dict[SandboxType, Type[Sandbox]] = {}
200
+
201
+ @classmethod
202
+ def register_sandbox(cls, sandbox_type: SandboxType, sandbox_class: Type[Sandbox]):
203
+ """Register a sandbox class.
204
+
205
+ Args:
206
+ sandbox_type: Sandbox type identifier
207
+ sandbox_class: Sandbox class
208
+ """
209
+ cls._sandboxes[sandbox_type] = sandbox_class
210
+
211
+ @classmethod
212
+ def create_sandbox(
213
+ cls,
214
+ sandbox_type: SandboxType,
215
+ config: Optional[Union[SandboxConfig, Dict]] = None,
216
+ sandbox_id: Optional[str] = None
217
+ ) -> Sandbox:
218
+ """Create a sandbox instance.
219
+
220
+ Args:
221
+ sandbox_type: Sandbox type
222
+ config: Sandbox configuration
223
+ sandbox_id: Optional sandbox ID
224
+
225
+ Returns:
226
+ Sandbox instance
227
+
228
+ Raises:
229
+ ValueError: If sandbox type is not registered
230
+ """
231
+ if sandbox_type not in cls._sandboxes:
232
+ raise ValueError(f'Sandbox type {sandbox_type} is not registered')
233
+
234
+ # Parse config based on sandbox type
235
+ if not config:
236
+ if sandbox_type == SandboxType.DOCKER:
237
+ config = DockerSandboxConfig()
238
+ elif sandbox_type == SandboxType.DOCKER_NOTEBOOK:
239
+ config = DockerNotebookConfig()
240
+ else:
241
+ config = SandboxConfig()
242
+ elif isinstance(config, dict):
243
+ if sandbox_type == SandboxType.DOCKER:
244
+ config = DockerSandboxConfig(**config)
245
+ elif sandbox_type == SandboxType.DOCKER_NOTEBOOK:
246
+ config = DockerNotebookConfig(**config)
247
+ else:
248
+ config = SandboxConfig(**config)
249
+
250
+ sandbox_class = cls._sandboxes[sandbox_type]
251
+ return sandbox_class(config, sandbox_id)
252
+
253
+ @classmethod
254
+ def get_available_types(cls) -> List[SandboxType]:
255
+ """Get list of available sandbox types.
256
+
257
+ Returns:
258
+ List of available sandbox types
259
+ """
260
+ return list(cls._sandboxes.keys())
261
+
262
+
263
+ def register_sandbox(sandbox_type: SandboxType):
264
+ """Decorator for registering sandboxes.
265
+
266
+ Args:
267
+ sandbox_type: Sandbox type identifier
268
+ """
269
+
270
+ def decorator(sandbox_class: Type[Sandbox]):
271
+ SandboxFactory.register_sandbox(sandbox_type, sandbox_class)
272
+ return sandbox_class
273
+
274
+ return decorator
@@ -0,0 +1,224 @@
1
+ # flake8: noqa E501
2
+ import asyncio
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+ from typing import Optional
8
+
9
+ from ms_enclave.utils import get_logger
10
+
11
+ from ..model import DockerNotebookConfig, SandboxStatus, SandboxType
12
+ from .base import register_sandbox
13
+ from .docker_sandbox import DockerSandbox
14
+
15
+ logger = get_logger()
16
+
17
+
18
+ @register_sandbox(SandboxType.DOCKER_NOTEBOOK)
19
+ class DockerNotebookSandbox(DockerSandbox):
20
+ """
21
+ Docker sandbox that executes Python code using Jupyter Kernel Gateway.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ config: DockerNotebookConfig,
27
+ sandbox_id: Optional[str] = None,
28
+ ):
29
+ """
30
+ Initialize the Docker-based Jupyter Kernel Gateway executor.
31
+
32
+ Args:
33
+ config: Docker sandbox configuration
34
+ sandbox_id: Optional sandbox ID
35
+ host: Host to bind to.
36
+ port: Port to bind to.
37
+ """
38
+ super().__init__(config, sandbox_id)
39
+
40
+ self.config: DockerNotebookConfig = config
41
+ self.host = self.config.host
42
+ self.port = self.config.port
43
+ self.kernel_id = None
44
+ self.ws = None
45
+ self.base_url = None
46
+ self.config.ports['8888/tcp'] = self.port
47
+ self.config.network_enabled = True # Ensure network is enabled for Jupyter
48
+
49
+ @property
50
+ def sandbox_type(self) -> SandboxType:
51
+ """Return sandbox type."""
52
+ return SandboxType.DOCKER_NOTEBOOK
53
+
54
+ async def start(self) -> None:
55
+ """Start the Docker container with Jupyter Kernel Gateway."""
56
+ try:
57
+ self.update_status(SandboxStatus.INITIALIZING)
58
+
59
+ # Initialize Docker client first
60
+ import docker
61
+ self.client = docker.from_env()
62
+
63
+ # Build Jupyter image if needed before creating container
64
+ await self._build_jupyter_image()
65
+
66
+ # Now start the base container with the Jupyter image
67
+ await super().start()
68
+
69
+ # Setup Jupyter kernel gateway services
70
+ await self._setup_jupyter()
71
+
72
+ self.update_status(SandboxStatus.RUNNING)
73
+
74
+ except Exception as e:
75
+ self.update_status(SandboxStatus.ERROR)
76
+ self.metadata['error'] = str(e)
77
+ logger.error(f'Failed to start Jupyter Docker sandbox: {e}')
78
+ raise RuntimeError(f'Failed to start Jupyter Docker sandbox: {e}')
79
+
80
+ async def _setup_jupyter(self) -> None:
81
+ """Setup Jupyter Kernel Gateway services in the container."""
82
+ try:
83
+ # Wait for Jupyter Kernel Gateway to be ready
84
+ await self._wait_for_jupyter_ready()
85
+
86
+ # Create kernel and establish websocket connection
87
+ await self._create_kernel()
88
+
89
+ except Exception as e:
90
+ logger.error(f'Failed to setup Jupyter: {e}')
91
+ raise
92
+
93
+ async def _wait_for_jupyter_ready(self) -> None:
94
+ """Wait for Jupyter Kernel Gateway to be ready."""
95
+ import requests
96
+
97
+ self.base_url = f'http://{self.host}:{self.port}'
98
+ max_retries = 10 # Wait up to 30 seconds
99
+ retry_interval = 3 # Check every 3 second
100
+
101
+ for attempt in range(max_retries):
102
+ try:
103
+ # Try to get the API status
104
+ response = requests.get(f'{self.base_url}/api', timeout=5)
105
+ if response.status_code == 200:
106
+ logger.info(f'Jupyter Kernel Gateway is ready at {self.base_url}')
107
+ return
108
+ except requests.exceptions.RequestException:
109
+ # Connection failed, Jupyter not ready yet
110
+ pass
111
+
112
+ if attempt < max_retries - 1:
113
+ logger.info(f'Waiting for Jupyter Kernel Gateway to be ready... (attempt {attempt + 1}/{max_retries})')
114
+ await asyncio.sleep(retry_interval)
115
+
116
+ raise RuntimeError(f'Jupyter Kernel Gateway failed to become ready within {max_retries} seconds')
117
+
118
+ async def _build_jupyter_image(self) -> None:
119
+ """Build or ensure Jupyter image exists."""
120
+ # Step 1: Try to get the image directly
121
+ try:
122
+ self.client.images.get(self.config.image)
123
+ logger.info(f'Using existing Docker image: {self.config.image}')
124
+ return
125
+ except Exception as e:
126
+ logger.debug(f'Direct image get failed: {e}, trying list method...')
127
+
128
+ # Step 2: Try to find image in list
129
+ image_exists = any(self.config.image in img.tags for img in self.client.images.list() if img.tags)
130
+ if image_exists:
131
+ logger.info(f'Using existing Docker image: {self.config.image}')
132
+ return
133
+
134
+ # Step 3: Image not found, build it
135
+ logger.info(f'Building Docker image {self.config.image}...')
136
+
137
+ # Create Dockerfile
138
+ dockerfile_content = dedent(
139
+ """\
140
+ FROM python:3.12-slim
141
+
142
+ RUN pip install jupyter_kernel_gateway jupyter_client ipykernel
143
+
144
+ # Install and register the Python kernel
145
+ RUN python -m ipykernel install --sys-prefix --name python3 --display-name "Python 3"
146
+
147
+ EXPOSE 8888
148
+ CMD ["jupyter", "kernelgateway", "--KernelGatewayApp.ip=0.0.0.0", "--KernelGatewayApp.port=8888", "--KernelGatewayApp.allow_origin=*"]
149
+ """
150
+ )
151
+
152
+ with tempfile.TemporaryDirectory() as tmpdir:
153
+ dockerfile_path = Path(tmpdir) / 'Dockerfile'
154
+ dockerfile_path.write_text(dockerfile_content)
155
+
156
+ # Build image with output
157
+ def build_image():
158
+ build_logs = self.client.images.build(
159
+ path=tmpdir, dockerfile='Dockerfile', tag=self.config.image, rm=True
160
+ )
161
+ # Process and log build output
162
+ for log in build_logs[1]: # build_logs[1] contains the build log generator
163
+ if 'stream' in log:
164
+ logger.info(f"[📦 {self.id}] {log['stream'].strip()}")
165
+ elif 'error' in log:
166
+ logger.error(f"[📦 {self.id}] {log['error']}")
167
+ return build_logs[0] # Return the built image
168
+
169
+ await asyncio.get_event_loop().run_in_executor(None, build_image)
170
+
171
+ async def _create_kernel(self) -> None:
172
+ """Create a new kernel and establish websocket connection."""
173
+ import requests
174
+
175
+ # Create new kernel via HTTP
176
+ response = requests.post(f'{self.base_url}/api/kernels')
177
+ if response.status_code != 201:
178
+ error_details = {
179
+ 'status_code': response.status_code,
180
+ 'headers': dict(response.headers),
181
+ 'url': response.url,
182
+ 'body': response.text,
183
+ 'request_method': response.request.method,
184
+ 'request_headers': dict(response.request.headers),
185
+ 'request_body': response.request.body,
186
+ }
187
+ raise RuntimeError(f'Failed to create kernel: {json.dumps(error_details, indent=2)}')
188
+
189
+ self.kernel_id = response.json()['id']
190
+
191
+ # Establish websocket connection
192
+ try:
193
+ from websocket import create_connection
194
+ ws_url = f'ws://{self.host}:{self.port}/api/kernels/{self.kernel_id}/channels'
195
+ self.ws = create_connection(ws_url)
196
+ logger.info(f'Kernel {self.kernel_id} created and connected')
197
+ except ImportError:
198
+ raise RuntimeError('websocket-client package is required. Install with: pip install websocket-client')
199
+
200
+ async def cleanup(self) -> None:
201
+ """Clean up Jupyter resources and Docker container."""
202
+ try:
203
+ # Close websocket connection
204
+ if self.ws:
205
+ try:
206
+ self.ws.close()
207
+ except Exception:
208
+ pass
209
+ self.ws = None
210
+
211
+ # Delete kernel
212
+ if self.kernel_id and self.base_url:
213
+ try:
214
+ import requests
215
+ requests.delete(f'{self.base_url}/api/kernels/{self.kernel_id}')
216
+ except Exception:
217
+ pass
218
+ self.kernel_id = None
219
+
220
+ except Exception as e:
221
+ logger.error(f'Error during Jupyter cleanup: {e}')
222
+
223
+ # Call parent cleanup
224
+ await super().cleanup()